|
|
@@ -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()
|
|
|
}
|
|
|
})
|