fix(ui-conversation): preserve semantic chat scroll position

This commit is contained in:
kingwl
2026-08-02 13:16:20 +08:00
committed by imccyu
parent 1cc59ae78e
commit 6514a59d5d
11 changed files with 395 additions and 63 deletions

View File

@@ -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<SessionId, number>()
// 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<SessionId, ChatScrollPosition>()
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 })

View File

@@ -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;

View File

@@ -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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-chat-anchor-key]')
: null
if (row !== null && list.contains(row)) return row
}
}
}
const rows = [...list.querySelectorAll<HTMLElement>('[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<ReturnType<ChatViewSlotProps['chatScroll']['read']>>
/** ui-slots' UseSession is deliberately wide (dependency direction); the
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
@@ -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 (
<div className={css.callRow} data-selected={selected || undefined}>
<div
className={css.callRow}
data-chat-anchor-key={`call:${node.callId}`}
data-chat-call-id={node.callId}
data-selected={selected || undefined}
>
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} t={t} />,
@@ -124,7 +196,12 @@ const CallRow = memo(function CallRow({
inspect: () => { inspectCall(callId) },
}), [callId, toolName, block, openFile, cwd, inspectCall])
return (
<div className={css.callRow} data-selected={selected || undefined}>
<div
className={css.callRow}
data-chat-anchor-key={`call:${callId}`}
data-chat-call-id={callId}
data-selected={selected || undefined}
>
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} t={t} />,
@@ -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 <AssistantMarkdown blocks={partial.blocks} streaming t={t} />
}
@@ -261,10 +334,12 @@ export function ChatView({
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
const listRef = useRef<HTMLDivElement | null>(null)
const columnRef = useRef<HTMLDivElement | null>(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<PagingAnchor | null>(null)
const firstSeqRef = useRef<number | null>(null)
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(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<HTMLElement>('[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 (
<ToolGroup
key={item.key}
renderSlot={renderSlot}
results={item.results}
openFile={openFile}
@@ -408,7 +525,6 @@ export function ChatView({
if (node.kind === 'assistant') {
return (
<AssistantMarkdown
key={item.key}
blocks={node.blocks}
streaming={false}
interrupted={node.interrupted}
@@ -421,13 +537,12 @@ export function ChatView({
)
}
if (node.kind === 'command') {
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} t={t} />
return <CommandRow renderSlot={renderSlot} node={node} t={t} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return (
<MessageItem
key={item.key}
node={node}
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
onFork={forkAt}
@@ -440,7 +555,7 @@ export function ChatView({
return (
<div className={css.root}>
<div ref={listRef} className={css.scroll}>
<div className={css.column}>
<div ref={columnRef} className={css.column} data-chat-flow="">
{openState === 'loading' && <div className={css.hint}>{t('chat.loadingHistory')}</div>}
{openState === 'error' && openError !== null && (
<div className={css.openError}>
@@ -454,8 +569,18 @@ export function ChatView({
</button>
</div>
)}
{items.map(renderItem)}
<StreamingTail useSession={useSession} onGrow={onGrow} t={t} />
{items.map(item => (
<div
key={item.key}
className={css.flowItem}
data-chat-anchor-key={item.kind === 'node' ? `node:${String(item.node.seq)}` : undefined}
data-chat-flow-key={item.key}
data-chat-flow-kind={item.kind === 'node' ? item.node.kind : 'tool-group'}
>
{renderItem(item)}
</div>
))}
<StreamingTail useSession={useSession} t={t} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map(call => (

View File

@@ -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

View File

@@ -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<ConversationSnapshot>) {
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<ChatViewSlotProps['chatScroll']['read']> = 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(<h.ChatView {...h.props} />)
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(<h.ChatView {...h.props} />)
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(<h.ChatView {...h.props} />)
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(<h.ChatView {...h.props} />)
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(<h.ChatView {...h.props} />)
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(<h.ChatView {...h.props} />)
@@ -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(<h.ChatView {...h.props} />)
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(<h.ChatView {...h.props} />)
@@ -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(<div />)
anchorTop = 560
host.scrollTop = 0
view.rerender(<h.ChatView {...h.props} />)
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(<h.ChatView {...h.props} />, { container: host })
expect(host.scrollTop).toBe(1_500)
expect(h.chatScroll.read()).toBeNull()
view.rerender(<div />)
host.scrollTop = 0
view.rerender(<h.ChatView {...h.props} />)
expect(host.scrollTop).toBe(1_500)
} finally {
rect.mockRestore()
host.remove()
}
})