diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml index 5689b73bf8..0fba7da8a2 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.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 .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md -2026-07-29-sticky-composer-conversation-scroll.md: 7ceae95dafffdb756ef49bb5612cd4e711eb59ca -2026-07-29-sticky-composer-conversation-scroll.zh.md: d925d82f94635b5fe67b0be119c041d003def393 +2026-07-29-sticky-composer-conversation-scroll.md: 1ec68cfbf2d78b2bda936822be6784f6fbd17a20 +2026-07-29-sticky-composer-conversation-scroll.zh.md: b163b3b08ce9664c7d0aad6b46fca1675f29ce1f diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md index 7ceae95daf..1ec68cfbf2 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md @@ -14,6 +14,8 @@ While a session exists, `ConversationRoot` always supplies a `wrapActiveBody` ow Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host. +Chat history prepend follows reader intent through stable rendered node/call identities rather than whole-scrollport height deltas. `ChatView` records the first visible `data-chat-anchor-key` and its top relative to the scrollport when paging starts, reselects the currently visible stable anchor after every reader scroll while the request is in flight, and compensates by that row's post-prepend rectangle delta. Reaching the bottom or appending the reader's own message cancels the paging anchor, so a late page cannot pull the view away from the newest content. ChatView's single `ResizeObserver` owns bottom-follow decisions for column and sticky-composer height changes: it follows streaming, tool disclosure, and draft resize only while bottom ownership remains pinned, without a second per-chunk scroll write. + ## Alternatives considered **Sticky header and sticky composer inside one column scrollport.** Rejected for the header: it must occupy the top as fixed layout chrome, not participate in the scrollport's sticky layer. @@ -26,4 +28,4 @@ Session stats live on `'conversation.composer.dock'` (above `'conversation.input ## Consequences -Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft. +Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow cannot overwrite a reader's newer scroll decision. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md index d925d82f94..b163b3b08c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md @@ -14,6 +14,8 @@ Status: implemented 会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。 +Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,`ChatView` 记录第一个可见的 `data-chat-anchor-key` 及其相对滚动容器的顶部位置;请求在途期间,每次读者滚动都会重新选择当前可见的稳定锚点;页面到达后则按该行矩形的前后差值补偿。到达底部或追加读者自己的消息会取消分页锚点,因此迟到的页面不能把视图从最新内容拉走。`ChatView` 的单个 `ResizeObserver` 统一负责消息列与 sticky 编辑器高度变化的贴底跟随决策:只有在仍贴底时才跟随流式输出、工具展开与草稿尺寸变化,且每个 chunk 不会触发第二次滚动写入。 + ## Alternatives considered **标题栏与编辑器都在同一列滚动容器内 sticky。** 标题栏否决:它必须作为固定布局 chrome 占据顶部,而不是参与滚动容器的 sticky 层。 @@ -26,4 +28,4 @@ Status: implemented ## Consequences -在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。hero → active 保持同一 textarea DOM 节点(assembled slash-flow 快照)以及 InputHub 草稿。 +在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排都不能覆盖读者更新的滚动决定。hero → active 保持同一 textarea DOM 节点(assembled slash-flow 快照)以及 InputHub 草稿。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml index 455c89ebcd..a2710c73f9 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.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 .agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md -2026-07-30-web-tool-row-unified-expand-and-inspect.md: ba2f4ead8023772fad578ca0b647241ecc332905 -2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: ac4835c7429a3ff7d3042f73d26d267911533132 +2026-07-30-web-tool-row-unified-expand-and-inspect.md: 98f1595564f0bd0d22f1ca4318b4c7fe15c6900d +2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: 8f00349a975f777cc4d556ade3a9abe9676b8848 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md index ba2f4ead80..98f1595564 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md @@ -10,14 +10,14 @@ The chat view's tool rows had drifted into per-surface interaction dialects: Too ## Decision -**Every expandable tool row shares one interaction — the whole row toggles (click / Enter / Space) with an icon→chevron hover preview — and one expanded body: an IN/OUT gutter-labeled card with per-section scroll caps; a hover-revealed Inspect pill jumps to the call's trajectory record through a one-shot store handoff; the chat view preserves its scroll offset across view switches through an in-memory per-session map.** +**Every expandable tool row shares one interaction — the whole row toggles (click / Enter / Space) with an icon→chevron hover preview — and one expanded body: an IN/OUT gutter-labeled card with per-section scroll caps; a hover-revealed Inspect pill jumps to the call's trajectory record through a one-shot store handoff; the chat view preserves its semantic reading position across view switches through an in-memory per-session map.** - `toolRowModel` now derives result material alongside args: `output` (the `resultText` flatten, moved from DetailsPanel into the contract), and `errorSummary` (the failure's first line, shown as the collapsed summary in the error color). A row with body, output, or terminal material is expandable; the row itself is the toggle (`role="button"`, `aria-expanded`), and file-path summaries stay independent links via `stopPropagation`. - The expanded card (figma 1249:35657) is a column of IN/OUT sections: each section is its own scrollport (max-height 150px) with a sticky gutter label, and the l2 divider spans the full card width. Think prose and the run_code CodeBlock keep their non-card bodies; context injection reuses the row with a label-less `plainBody` card. - `terminalFailed` reads a settled terminal card's exit status so BashRow and GenericToolCard surface a failing command as the row's red state dot — the only failure signal the collapsed row has, since the call itself settles `isError:false`. - TerminalBlock's banner joins the same reading model: it shares the card surface (no banner token), an l2 hairline separates it from the body, the command column caps at 150px and scrolls with sticky copy/status controls top-aligned to the first prompt row. - Inspect: `ToolRowOwnerProps.inspect` (absent for rows without a call identity) renders a pill in real flow under the expanded body's bottom-left, revealed by hovering anywhere on the tool call. Clicking writes `{ callId }` to the chat store's one-shot `inspect` field and switches to the trajectory view; TrajectoryTable finds the record, opens its summary, and acknowledges by clearing the field. -- Scroll preservation: the chat view saves its offset on every scroll (null when pinned to bottom) into an apply-scope `Map` exposed as `chatScroll` on the injected props; the open-jump branch restores it on remount. Deliberately not persisted — a fresh page load keeps the open-jump-to-bottom default. +- Scroll preservation: on every non-bottom scroll, the chat view saves `{ anchorKey, anchorTop, scrollTop }` into an apply-scope per-session map exposed as `chatScroll`; a remount first uses `scrollTop` to reach the approximate window, then corrects by the stable node/call anchor's rectangle delta so width reflow keeps the same reading row in place. Every pinned path, including Back to bottom, clears the entry synchronously before a tab or session switch. The map remains deliberately unpersisted — a fresh page load keeps the open-jump-to-bottom default. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md index ac4835c742..8f00349a97 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md @@ -10,14 +10,14 @@ ## 决定 -**所有可展开工具行共享同一交互——整行即开关(点击 / Enter / 空格),图标 hover 时渐变为 chevron 预览——以及同一展开体:带 IN/OUT 侧栏标签的卡片,各分区独立滚动上限;hover 显示的 Inspect 胶囊通过 store 的一次性交接跳到该调用的 trajectory 记录;聊天视图用内存态的按会话 Map 在视图切换间保留滚动位置。** +**所有可展开工具行共享同一交互——整行即开关(点击 / Enter / 空格),图标 hover 时渐变为 chevron 预览——以及同一展开体:带 IN/OUT 侧栏标签的卡片,各分区独立滚动上限;hover 显示的 Inspect 胶囊通过 store 的一次性交接跳到该调用的 trajectory 记录;聊天视图用内存态的按会话 Map 在视图切换间保留语义阅读位置。** - `toolRowModel` 在 args 之外同时派生结果材料:`output`(`resultText` 拍平逻辑从 DetailsPanel 移入 contract)和 `errorSummary`(失败首行,以错误色作为折叠摘要)。有 body、output 或 terminal 材料的行即可展开;行本身是开关(`role="button"`、`aria-expanded`),文件路径摘要通过 `stopPropagation` 保持独立链接。 - 展开卡片(figma 1249:35657)是 IN/OUT 分区列:每个分区是独立滚动区(max-height 150px),侧栏标签 sticky 固定,l2 分割线横贯整卡宽度。Think 的推理文本和 run_code 的 CodeBlock 保持非卡片体;上下文注入复用此行并以无标签的 `plainBody` 卡片展开。 - `terminalFailed` 读取已结算 terminal 卡片的退出状态,让 BashRow 和 GenericToolCard 把失败命令显示为行的红色状态点——这是折叠行唯一的失败信号,因为调用本身结算为 `isError:false`。 - TerminalBlock 的横幅并入同一阅读模型:与卡片共用同一表面(不再用 banner token),与正文之间是 l2 细线,命令列上限 150px 内部滚动,复制/状态控件 sticky 且顶对齐第一行提示符。 - Inspect:`ToolRowOwnerProps.inspect`(无调用身份的行不提供)在展开体左下角以真实布局位置渲染胶囊,hover 整个 tool call 任意位置显示。点击将 `{ callId }` 写入 chat store 的一次性 `inspect` 字段并切换到 trajectory 视图;TrajectoryTable 找到记录、打开其摘要,并通过清空字段确认。 -- 滚动保留:聊天视图在每次滚动时保存偏移(贴底时为 null)到 apply 作用域的 `Map`,经注入 props 的 `chatScroll` 暴露;重挂载时 open-jump 分支恢复它。刻意不持久化——新页面加载保持打开即贴底的默认行为。 +- 滚动保留:每次非贴底滚动时,聊天视图把 `{ anchorKey, anchorTop, scrollTop }` 保存到 apply 作用域的按会话 Map,并经注入 props 的 `chatScroll` 暴露;重挂载时先用 `scrollTop` 到达近似窗口,再按稳定 node/call 锚点的矩形差值校正,因此宽度重排后仍把同一阅读行保持在原位。包括「回到底部」在内的每条贴底路径都会在切换 tab 或会话前同步清除该项。Map 仍刻意不持久化——新页面加载保持打开即贴底的默认行为。 ## 曾考虑的替代方案 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index ec1f9f7ab9..4f662f3ab6 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -7,7 +7,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type { ViewTab } from './contract/views.ts' import type { - ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected, + ApprovalWait, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected, ConversationSessionInjected, DetailsInjected, } from './contract/slots.ts' import type { InputNotice } from './input/contract.ts' @@ -113,10 +113,10 @@ export function apply(ctx: Context): void { return () => { row.dispose() } }, 'ui-conversation: Enter behavior settings row') - // Chat scroll offsets by session, surviving view switches (the chat view - // unmounts under the tab ring). Deliberately not persisted: a fresh page - // load should keep the open-jump-to-bottom default. - const chatScrollTops = new Map() + // Chat semantic reader positions by session, surviving view switches and + // width reflow when the tab ring remounts the view. Deliberately not + // persisted: a fresh page load keeps the open-jump-to-bottom default. + const chatScrollPositions = new Map() const viewTabs = (): ViewTab[] => { const tabs: ViewTab[] = [] @@ -316,11 +316,11 @@ export function apply(ctx: Context): void { actions.setView('trajectory') }, chatScroll: { - save: (top) => { - if (top === null) chatScrollTops.delete(sessionId) - else chatScrollTops.set(sessionId, top) + save: (position) => { + if (position === null) chatScrollPositions.delete(sessionId) + else chatScrollPositions.set(sessionId, position) }, - read: () => chatScrollTops.get(sessionId) ?? null, + read: () => chatScrollPositions.get(sessionId) ?? null, }, forkAt: (seq) => { sessions.fork({ sessionId, atSeq: seq, increaseTitle: true }) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index 55270946ba..87f965b9a4 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -42,6 +42,12 @@ gap: 16px; } +/* Settled-flow identity boundary. It is neutral today and becomes the natural + measurement/mount unit for a virtualizer without changing the column gap. */ +.flowItem { + min-width: 0; +} + .toolGroup { display: flex; flex-direction: column; diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 076da6c6ab..2f62d57591 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -44,6 +44,59 @@ function scrollerOf(from: HTMLElement): HTMLElement { return (from.closest('[data-conversation-scroll]')) ?? from } +interface PagingAnchor { + /** Stable node/call identity, independent of boundary-spanning group keys. */ + key: string + /** Row top relative to the scrollport after the latest user scroll. */ + top: number +} + +/** Find an already-rendered settled row without interpolating a selector. */ +function anchorElement(list: HTMLElement, key: string): HTMLElement | null { + for (const row of list.querySelectorAll('[data-chat-anchor-key]')) { + if (row.dataset.chatAnchorKey === key) return row + } + return null +} + +/** Row position in scrollport coordinates (viewport-independent). */ +function flowTop(row: HTMLElement, scrollport: HTMLElement): number { + return row.getBoundingClientRect().top - scrollport.getBoundingClientRect().top +} + +/** Select a visible stable node/call identity, falling back only when layout + * has not exposed a visible box yet. */ +function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement | null { + const viewport = scrollport.getBoundingClientRect() + const composer = scrollport.querySelector('[data-composer-seat]') + const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom + // Scroll events are hot: hit-test a few points through the stretched flow + // rows before considering the full mounted set. The fallback keeps jsdom + // and pre-layout states deterministic; a virtualizer naturally bounds it. + if (typeof document.elementsFromPoint === 'function' && visibleBottom > viewport.top) { + const content = list.getBoundingClientRect() + const left = Math.max(viewport.left, content.left) + const right = Math.min(viewport.right, content.right) + const x = left + Math.max(0, right - left) / 2 + const height = visibleBottom - viewport.top + const points = [1, Math.min(32, height / 3), height / 2, Math.max(1, height - 1)] + for (const offset of points) { + for (const element of document.elementsFromPoint(x, viewport.top + offset)) { + const row = element instanceof HTMLElement + ? element.closest('[data-chat-anchor-key]') + : null + if (row !== null && list.contains(row)) return row + } + } + } + const rows = [...list.querySelectorAll('[data-chat-anchor-key]')] + const visibleRows = rows.filter((row) => { + const rect = row.getBoundingClientRect() + return rect.bottom > viewport.top && rect.top < visibleBottom + }) + return visibleRows[0] ?? rows[0] ?? null +} + type OpenFile = (path: string) => void type InspectCall = (callId: string) => void @@ -51,6 +104,8 @@ type InspectCall = (callId: string) => void /** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */ type RenderToolRow = ChatViewSlotProps['renderSlot'] +type ChatScrollPosition = NonNullable> + /** ui-slots' UseSession is deliberately wide (dependency direction); the * chat view narrows once to the runtime snapshot the binding actually feeds. */ type UseConversation = SnapshotSelectorHook @@ -66,6 +121,18 @@ function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): n return null } +/** Capture a reflow-resistant reader position from the current rendered window. */ +function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollPosition | null { + const row = pagingAnchor(list, scrollport) + const anchorKey = row?.dataset.chatAnchorKey + if (row === null || anchorKey === undefined) return null + return { + anchorKey, + anchorTop: flowTop(row, scrollport), + scrollTop: scrollport.scrollTop, + } +} + /** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a * top-level call (same registrations, same fallback), nested by the parent. * A started-but-unsettled sub-call arrives as the RunningToolCall shape and @@ -86,7 +153,12 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select inspect: () => { inspectCall(node.callId) }, }), [node, toolName, openFile, cwd, inspectCall]) return ( -
+
{renderSlot('conversation.chat.toolview', owner, { entryKey: toolName, fallback: , @@ -124,7 +196,12 @@ const CallRow = memo(function CallRow({ inspect: () => { inspectCall(callId) }, }), [callId, toolName, block, openFile, cwd, inspectCall]) return ( -
+
{renderSlot('conversation.chat.toolview', owner, { entryKey: toolName, fallback: , @@ -213,17 +290,13 @@ function TurnStatus() { ) } -/** The streaming partial, isolated so chunk batches re-render only this tail. - * onGrow lets the scroll owner follow content the parent never re-renders for. */ -function StreamingTail({ useSession, onGrow, t }: { +/** The streaming partial, isolated so chunk batches re-render only this tail; + * the column ResizeObserver owns bottom-follow when its box grows. */ +function StreamingTail({ useSession, t }: { useSession: UseConversation - onGrow: () => void t: ChatViewSlotProps['t'] }) { const partial = useSession(s => s.partial) - useLayoutEffect(() => { - onGrow() - }) if (partial === null) return null return } @@ -261,10 +334,12 @@ export function ChatView({ const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) const listRef = useRef(null) + const columnRef = useRef(null) const atBottomRef = useRef(true) const [atBottom, setAtBottom] = useState(true) - /** Paging anchor: height/position at click, compensated after the prepend lands. */ - const anchorRef = useRef<{ h: number; t: number } | null>(null) + /** Paging anchor: semantic row/position at click, updated by reader scrolls + * while the request is pending and restored after the prepend lands. */ + const anchorRef = useRef(null) const firstSeqRef = useRef(null) const openedRef = useRef(false) const lastKeyRef = useRef(null) @@ -281,9 +356,11 @@ export function ChatView({ const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}` const toBottom = (el: HTMLElement): void => { + anchorRef.current = null el.scrollTop = el.scrollHeight atBottomRef.current = true setAtBottom(true) + chatScroll.save(null) } useLayoutEffect(() => { @@ -300,10 +377,15 @@ export function ChatView({ if (saved === null) { toBottom(el) } else { - el.scrollTop = saved + el.scrollTop = saved.scrollTop + const row = anchorElement(local, saved.anchorKey) + if (row !== null) el.scrollTop += flowTop(row, el) - saved.anchorTop const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1 atBottomRef.current = isAtBottom setAtBottom(isAtBottom) + const normalized = isAtBottom ? null : scrollPosition(local, el) + if (isAtBottom) chatScroll.save(null) + else if (normalized !== null) chatScroll.save(normalized) } firstSeqRef.current = firstSeq lastKeyRef.current = lastKey @@ -311,10 +393,14 @@ export function ChatView({ followSigRef.current = followSig return } - // Prepend (head seq decreased): compensate by the height delta. + // Prepend (head seq decreased): preserve the same settled row at the + // position established by the reader's latest scroll. This excludes + // unrelated tail/composer growth while the request was in flight. if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) { - el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h) + const anchor = anchorRef.current anchorRef.current = null + const row = anchorElement(local, anchor.key) + if (row !== null) el.scrollTop += flowTop(row, el) - anchor.top firstSeqRef.current = firstSeq /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */ lastKeyRef.current = lastKey @@ -346,9 +432,16 @@ export function ChatView({ const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1 atBottomRef.current = isAtBottom setAtBottom(isAtBottom) + const position = isAtBottom ? null : scrollPosition(local, el) + if (isAtBottom) { + anchorRef.current = null + } else if (anchorRef.current !== null && position !== null) { + anchorRef.current = { key: position.anchorKey, top: position.anchorTop } + } // Continuous save (unmount happens after ref detach, so saving there is // too late); pinned-to-bottom clears so a remount keeps following. - chatScroll.save(isAtBottom ? null : el.scrollTop) + if (isAtBottom) chatScroll.save(null) + else if (position !== null) chatScroll.save(position) } // Bind scroll to the resolved scrollport (host or local) once per mount. @@ -362,7 +455,6 @@ export function ChatView({ return () => { el.removeEventListener('scroll', onScroll) } }, []) - // Follow streaming growth the parent never re-renders for (stable ref). // The ref starts null and is assigned every render, so the placeholder // initializer a function initial value would need never exists. const followRef = useRef<(() => void) | null>(null) @@ -371,16 +463,42 @@ export function ChatView({ if (local !== null && atBottomRef.current) { const el = scrollerOf(local) el.scrollTop = el.scrollHeight + chatScroll.save(null) } } - const onGrow = useRef(() => followRef.current?.()).current + // Streaming, tool disclosures, and other flow changes resize the column; + // the sticky composer resizes outside it. This observer owns ChatView's + // dynamic-height follow decisions and writes only while the reader is pinned. + useEffect(() => { + const column = columnRef.current + const local = listRef.current + if (column === null || local === null || typeof ResizeObserver === 'undefined') return + const scrollport = scrollerOf(local) + const composer = scrollport.querySelector('[data-composer-seat]') + const observer = new ResizeObserver(() => { followRef.current?.() }) + observer.observe(column) + if (composer !== null) observer.observe(composer) + return () => { observer.disconnect() } + }, []) + + // A failed/empty page leaves the head unchanged. Once the request leaves + // its busy state there is no future prepend for the saved anchor to own. + useEffect(() => { + if (!loadingOlder) anchorRef.current = null + }, [loadingOlder]) const loadOlderAnchored = (): void => { const local = listRef.current /* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */ if (local !== null) { const el = scrollerOf(local) - anchorRef.current = { h: el.scrollHeight, t: el.scrollTop } + const row = pagingAnchor(local, el) + if (row !== null && row.dataset.chatAnchorKey !== undefined) { + anchorRef.current = { + key: row.dataset.chatAnchorKey, + top: flowTop(row, el), + } + } } loadOlder() } @@ -392,7 +510,6 @@ export function ChatView({ || codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true) return ( + return } /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ if (node.kind === 'tool-result') return null return (
-
+
{openState === 'loading' &&
{t('chat.loadingHistory')}
} {openState === 'error' && openError !== null && (
@@ -454,8 +569,18 @@ export function ChatView({
)} - {items.map(renderItem)} - + {items.map(item => ( +
+ {renderItem(item)} +
+ ))} + {runningCalls.length > 0 && (
{runningCalls.map(call => ( diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 20a791c556..6abfb592f5 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -435,6 +435,16 @@ export class PendingApproval { export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } & PropsLocale<'conversation'> +/** In-memory reader position resilient to transcript width reflow. */ +export interface ChatScrollPosition { + /** Stable rendered node/call identity nearest the visible reading edge. */ + readonly anchorKey: string + /** Anchor top relative to the transcript scrollport when saved. */ + readonly anchorTop: number + /** Approximate offset used before the semantic anchor is measured. */ + readonly scrollTop: number +} + /** * Injected share of the chat view entry: the two callbacks whose targets live * outside the view (layout orchestration; the session object layer). @@ -456,10 +466,10 @@ export interface ChatViewInjected { * fresh page load starts empty and keeps the open-jump-to-bottom default. */ chatScroll: { - /** Record the scroll offset; null clears it (pinned to bottom). */ - save: (top: number | null) => void - /** Last recorded offset, or null when pinned or never recorded. */ - read: () => number | null + /** Record a semantic reader position; null clears it when pinned. */ + save: (position: ChatScrollPosition | null) => void + /** Last reader position, or null when pinned or never recorded. */ + read: () => ChatScrollPosition | null } /** Fork through the completed turn ending at the eligible message `seq`, then open the child. */ forkAt: (seq: number) => void diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index a041aca214..6641f1f589 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -22,7 +22,10 @@ import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs } from '../src/client/chat/chat-flow.ts' -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) // Keyless create() persists under the bare declared key; clear between cases // so one harness's selection cannot rehydrate into the next. beforeEach(() => { @@ -112,10 +115,10 @@ function makeHarness(init?: Partial) { const loadOlder = vi.fn() const inspectCall = vi.fn<(callId: string) => void>() // In-memory scroll memory matching the apply.ts per-session map contract. - let savedScrollTop: number | null = null - const chatScroll = { - save: (top: number | null) => { savedScrollTop = top }, - read: () => savedScrollTop, + let savedScroll: ReturnType = null + const chatScroll: ChatViewSlotProps['chatScroll'] = { + save: (position) => { savedScroll = position }, + read: () => savedScroll, } const forkAt = vi.fn() // Selection rides the REAL chat store (same construction path as @@ -247,20 +250,38 @@ describe('ChatView', () => { expect(view.getByText('w1')).toBeTruthy() }) - it('prepend keeps the viewport anchored when the reader is NOT at the bottom (no lastKey force)', () => { - // Covers the prepend early-return arm where lastItem exists but the key - // path is not taken (anchor branch wins before the appended-user check). - const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true }) + it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => { + const h = makeHarness({ nodes: [user(9, 'first visible'), user(10, 'next visible')], hasMore: true }) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const first = view.container.querySelector('[data-chat-flow-key="n9"]') as HTMLDivElement + const next = view.container.querySelector('[data-chat-flow-key="n10"]') as HTMLDivElement + let firstTop = 100 + let nextTop = 300 + vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation( + () => ({ top: 0, bottom: 200 } as DOMRect), + ) + vi.spyOn(first, 'getBoundingClientRect').mockImplementation( + () => ({ top: firstTop, bottom: firstTop + 40 } as DOMRect), + ) + vi.spyOn(next, 'getBoundingClientRect').mockImplementation( + () => ({ top: nextTop, bottom: nextTop + 40 } as DOMRect), + ) Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true }) scroller.scrollTop = 50 fireEvent.scroll(scroller) fireEvent.click(view.getByText('加载更早')) + // The reader moves after the request starts; this, not the click-time + // row, is the intent the arriving page must preserve. + firstTop = -200 + nextTop = 60 + scroller.scrollTop = 90 + fireEvent.scroll(scroller) Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true }) - act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) }) - expect(scroller.scrollTop).toBe(550) // 50 + (1300 - 800) + nextTop = 560 + act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] }) }) + expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift }) it('renders the fixture main line: bubble, narration, grouped tool rows', () => { @@ -272,6 +293,18 @@ describe('ChatView', () => { expect(view.getByText('running tools')).toBeTruthy() expect(view.getAllByText('Bash')).toHaveLength(2) expect(view.getByText('run a')).toBeTruthy() + expect([...view.container.querySelectorAll('[data-chat-flow-key]')].map(row => ({ + key: row.getAttribute('data-chat-flow-key'), + kind: row.getAttribute('data-chat-flow-kind'), + }))).toEqual([ + { key: 'n1', kind: 'user' }, + { key: 'n2', kind: 'assistant' }, + { key: 'g3', kind: 'tool-group' }, + ]) + expect([...view.container.querySelectorAll('[data-chat-call-id]')].map(row => row.getAttribute('data-chat-call-id'))) + .toEqual(['a', 'b']) + expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key'))) + .toEqual(['node:1', 'node:2', 'call:a', 'call:b']) }) it('renders Host-pending steering at the flow tail and hands off to the durable node', () => { @@ -622,23 +655,103 @@ describe('ChatView', () => { expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }]) }) - it('prepend compensates scrollTop by the height delta; a trailing user node force-scrolls', () => { + it('prepend preserves a semantic row; a trailing user node force-scrolls', () => { const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true }) const view = render() const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement // jsdom has no layout: fake the metrics the anchor math reads. Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true }) + const anchored = view.container.querySelector('[data-chat-flow-key="n5"]') as HTMLDivElement + let anchoredTop = 100 + vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation( + () => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect), + ) + scroller.scrollTop = 80 + fireEvent.scroll(scroller) // Arm the paging anchor, then deliver an older page (head seq decreases). fireEvent.click(view.getByText('加载更早')) Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true }) + anchoredTop = 700 act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] }) }) - expect(scroller.scrollTop).toBe(600) // 0 + (1600 - 1000) + expect(scroller.scrollTop).toBe(680) // reader offset 80 + the anchored row's 600px shift // A new trailing user bubble (own words) force-scrolls to the bottom. act(() => { h.set({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')] }) }) expect(scroller.scrollTop).toBe(1600) }) + it('uses stable call identity when a prepend changes the tool-group key amid unrelated growth', () => { + const h = makeHarness({ nodes: [toolResult(5, 'late')], hasMore: true }) + const view = render() + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + let prepended = false + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.dataset.chatAnchorKey === 'call:late') { + const top = prepended ? 400 : 100 + return { top, bottom: top + 40 } as DOMRect + } + return { top: 0, bottom: 200 } as DOMRect + }) + try { + Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true }) + Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true }) + scroller.scrollTop = 80 + fireEvent.scroll(scroller) + fireEvent.click(view.getByText('加载更早')) + // Total height grows by 500, but only 300 belongs before the call row. + Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true }) + prepended = true + act(() => { h.set({ nodes: [toolResult(4, 'early'), toolResult(5, 'late')] }) }) + expect(scroller.scrollTop).toBe(380) + } finally { + rect.mockRestore() + } + }) + + it('uses the latest retry identity when prepending an earlier retry changes the flow key', () => { + const h = makeHarness({ nodes: [retry(5)], hasMore: true }) + const view = render() + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + let prepended = false + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.dataset.chatAnchorKey === 'node:5') { + const top = prepended ? 400 : 100 + return { top, bottom: top + 40 } as DOMRect + } + return { top: 0, bottom: 200 } as DOMRect + }) + try { + Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true }) + Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true }) + scroller.scrollTop = 80 + fireEvent.scroll(scroller) + fireEvent.click(view.getByText('加载更早')) + Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true }) + prepended = true + act(() => { h.set({ nodes: [retry(4), retry(5)] }) }) + expect(scroller.scrollTop).toBe(380) + expect(view.container.querySelector('[data-chat-flow-key="n4"][data-chat-anchor-key="node:5"]')).not.toBeNull() + } finally { + rect.mockRestore() + } + }) + + it('back-to-bottom cancels an in-flight paging anchor', () => { + const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true }) + const view = render() + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true }) + Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true }) + scroller.scrollTop = 50 + fireEvent.scroll(scroller) + fireEvent.click(view.getByText('加载更早')) + fireEvent.click(view.getByLabelText('回到底部')) + Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true }) + act(() => { h.set({ nodes: [assistant(2, 'older'), user(9, 'late')] }) }) + expect(scroller.scrollTop).toBe(1_300) + expect(h.chatScroll.read()).toBeNull() + }) + it('scrolling away disables follow and shows the back-to-bottom button; clicking returns', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() @@ -658,6 +771,36 @@ describe('ChatView', () => { expect(view.queryByLabelText('回到底部')).toBeNull() }) + it('one ResizeObserver owns pinned dynamic-height follow and ignores growth while away', () => { + let notify: (() => void) | undefined + const observe = vi.fn() + class ResizeObserverStub { + constructor(callback: ResizeObserverCallback) { + notify = () => { callback([], this as unknown as ResizeObserver) } + } + + observe = observe + disconnect = vi.fn() + } + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) + const view = render() + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true }) + Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) + scroller.scrollTop = 700 + fireEvent.scroll(scroller) + Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true }) + act(() => { notify?.() }) + expect(scroller.scrollTop).toBe(1_200) + scroller.scrollTop = 200 + fireEvent.scroll(scroller) + Object.defineProperty(scroller, 'scrollHeight', { value: 1_400, writable: true }) + act(() => { notify?.() }) + expect(scroller.scrollTop).toBe(200) + expect(observe).toHaveBeenCalledTimes(1) + }) + it('entering the at-bottom threshold does not snap the remaining scroll distance', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() @@ -694,13 +837,23 @@ describe('ChatView', () => { } }) - it('a remount restores the saved scroll position instead of re-jumping to the bottom', () => { + it('a remount restores the saved semantic row after width reflow', () => { const host = document.createElement('div') host.setAttribute('data-conversation-scroll', '') Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true }) Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true }) Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true }) document.body.appendChild(host) + let anchorTop = 80 + vi.spyOn(host, 'getBoundingClientRect').mockImplementation( + () => ({ top: 0, bottom: 500 } as DOMRect), + ) + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.dataset.chatAnchorKey === 'node:1') { + return { top: anchorTop, bottom: anchorTop + 40 } as DOMRect + } + return { top: 0, bottom: 40 } as DOMRect + }) try { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) // Fresh open (nothing saved): the bottom jump stands. @@ -711,12 +864,46 @@ describe('ChatView', () => { fireEvent.scroll(host) // View-tab switch away and back: the view unmounts, then remounts. view.rerender(
) + anchorTop = 560 host.scrollTop = 0 view.rerender() - expect(host.scrollTop).toBe(100) + expect(host.scrollTop).toBe(580) // approximate 100 + the row's 480px reflow shift // The restored position is above the floor: follow stays disarmed. expect(view.getByLabelText('回到底部')).toBeTruthy() } finally { + rect.mockRestore() + host.remove() + } + }) + + it('normalizes a semantic restore clamped to the bottom before an immediate remount', () => { + const host = document.createElement('div') + host.setAttribute('data-conversation-scroll', '') + Object.defineProperty(host, 'scrollHeight', { value: 2_000, writable: true, configurable: true }) + Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true }) + let scrollTop = 0 + Object.defineProperty(host, 'scrollTop', { + configurable: true, + get: () => scrollTop, + set: (value: number) => { scrollTop = Math.min(value, 1_500) }, + }) + document.body.appendChild(host) + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.dataset.chatAnchorKey === 'node:1') return { top: 300, bottom: 340 } as DOMRect + return { top: 0, bottom: 500 } as DOMRect + }) + try { + const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) + h.chatScroll.save({ anchorKey: 'node:1', anchorTop: 80, scrollTop: 1_400 }) + const view = render(, { container: host }) + expect(host.scrollTop).toBe(1_500) + expect(h.chatScroll.read()).toBeNull() + view.rerender(
) + host.scrollTop = 0 + view.rerender() + expect(host.scrollTop).toBe(1_500) + } finally { + rect.mockRestore() host.remove() } })