fix(chat): keep floating controls outside transcript clipping

This commit is contained in:
imccyu committed 2026-09-22 21:07:55 +08:00
1 parent 3dcfbf2094
commit 6b4055bf32
8 files changed
+190 -117

No files matched your search

@@ -524,6 +524,54 @@ describe('web e2e: long Chat scroll contract', () => {
await browser?.close()
})
it.skipIf(MODE === 'record')('keeps floating controls anchored outside the clipped transcript', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-floating-controls',
seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }],
}, async (world) => {
await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns))
const host = world.page.locator('[data-conversation-scroll]')
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
const expectControls = async (): Promise<void> => {
await expect.poll(() => host.evaluate((element) => {
const rail = element.querySelector('nav[aria-label="Turn navigation"]')
const button = element.querySelector('button[aria-label="Back to bottom"]')
const composer = element.querySelector('[data-composer-seat]')
if (rail === null || button === null || composer === null) return Infinity
const viewport = element.getBoundingClientRect()
const railBox = rail.getBoundingClientRect()
const buttonBox = button.getBoundingClientRect()
const composerBox = composer.getBoundingClientRect()
const top = viewport.top + element.clientTop
return Math.max(
Math.abs((railBox.top + railBox.bottom) / 2 - (top + composerBox.top) / 2),
Math.abs(viewport.left + element.clientLeft + element.clientWidth - railBox.right - 12),
Math.abs(composerBox.top - buttonBox.bottom - 16),
)
}), { timeout: 10_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
}
await wheelTranscript(world.page, -1_200)
await backToBottom.waitFor({ timeout: 10_000 })
await expectControls()
await wheelTranscript(world.page, -1_200)
await expectControls()
const seat = world.page.locator('[data-composer-seat]')
const initialHeight = await seat.evaluate(element => element.getBoundingClientRect().height)
const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last()
await composer.fill(Array.from({ length: 8 }, (_, index) => `draft line ${index}`).join('\n'))
await expect.poll(() => seat.evaluate(element => element.getBoundingClientRect().height))
.toBeGreaterThan(initialHeight + 40)
await expectControls()
await composer.fill('')
await expectControls()
await backToBottom.click()
await expectBottom(world.page)
assertClean(world)
})
})
it.skipIf(MODE === 'record')('preserves the reader anchor when history and streaming arrive concurrently', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-history-stream',
+2 -2
View File
@@ -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-chat/README.md
README.md: 285242c9dc9b8225711096aed03f183f5a8e36b7
README.zh.md: 8844c9c361f7404af4ea22c645f18e2c9c4e1799
README.md: 928165d9d103bc4c83fbec92f11abc2f8eb3abb4
README.zh.md: ebb4cc986bdaa8e5eff49a4b32538db437ef9f30
+2
View File
@@ -100,6 +100,8 @@ The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and
Chat restores semantic anchors across history prepend and renderer remounts, with browser scroll anchoring disabled on its scrollport only while following the tail. Pinned scroll deliveries without reader movement, and reader input that reaches the exact floor, update follow ownership immediately, before subsequent layout changes can invalidate their floor. Other reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. Submitting transcript input or steering immediately restores tail following and clears an older pending reader sample. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer.
The turn rail and back-to-bottom button sit outside the clipped transcript. They use the shared conversation scrollport for sticky positioning, or the Chat frame for absolute positioning when Chat owns its scrollport.
The turn rail mounts only visible marks, overscan, and the focused mark's neighbors. Its fixed pitch and observed viewport size determine scroll offsets without reading the DOM scroll extent. Initial placement waits for the body's restored active Turn and the rail's first usable viewport size. The ref controls activate a Turn or scroll the rail independently; the transcript itself remains fully mounted.
While the pointer is outside the rail, automatic follow keeps the rail still when the active mark's center is inside the fade-free band and centers it after it leaves that band. Previews follow pointer movement or focus; marks scrolling under a stationary pointer do not select another preview.
+2
View File
@@ -100,6 +100,8 @@ Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure`
Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且只在跟随底部时禁用自己滚动区域的浏览器自动锚定。没有读者移动的贴底滚动事件,以及确实到达最底部的读者输入,会立即更新跟随归属,避免后续布局变化使其底部位置失效。其他读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。提交正文输入或 steering 时立即恢复跟随底部,并清除先前待处理的读者滚动采样。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。
轮次轨道与回到底部按钮位于正文裁剪层外。它们相对共享会话滚动容器做 sticky 定位;Chat 自己持有滚动容器时,则相对 Chat 外框做 absolute 定位。
轮次轨道只挂载可见刻度、预加载范围及键盘焦点刻度的相邻项。固定间距和观察到的视口尺寸决定滚动偏移,不读取 DOM 滚动总高度。初始定位等待正文恢复活跃轮次,以及轨道首次获得可用视口尺寸。ref 控制接口分别支持激活轮次和仅滚动轨道;正文自身仍完整挂载。
指针位于轨道外时,自动跟随在活跃刻度中心处于非渐隐区域内时保持轨道不动,越界后才将其居中。预览随指针移动或焦点切换;刻度在静止指针下滚动不会选中另一个预览。
@@ -1,9 +1,19 @@
.frame {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 auto;
}
.root {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 auto;
overflow-y: hidden;
}
.scroll {
@@ -25,6 +35,7 @@
overflow-anchor: none;
}
:global([data-conversation-scroll]) .frame,
:global([data-conversation-scroll]) .root {
flex: 0 0 auto;
min-height: auto;
@@ -113,12 +124,13 @@
opacity: 0.6;
}
/* Back-to-bottom: zero-height sticky slot so the control does not extend
scrollHeight; the button translates up into the viewport. Under the
conversation host, clearance sits above the sticky composer stack. */
/* Floating controls sit outside the clipped transcript. A standalone Chat
anchors them to its frame; the shared host supplies the sticky scrollport. */
.toBottomSlot {
position: sticky;
position: absolute;
right: 0;
bottom: 16px;
left: 0;
/* Above the sticky composer (z-index 7) so the control stays clickable and
visible over the input card. Exception: while an @/slash menu is open the
seat lifts to 9 (ui-conversation ConversationRoot) so the menu is never
@@ -127,11 +139,15 @@
height: 0;
display: flex;
justify-content: flex-end;
padding-right: max(0px, calc((100% - var(--dsh-chat-content-width)) / 2));
padding-right: max(
calc(var(--dsh-composer-side-clearance) + 16px),
calc((100% - var(--dsh-chat-content-width)) / 2)
);
pointer-events: none;
}
:global([data-conversation-scroll]) .toBottomSlot {
position: sticky;
/* Clears the sticky composer stack (docks + input card + stats): the live
height rides --dsh-composer-height (ConversationRoot's seat observer) so
the control follows a growing textarea; the fallback covers the first
@@ -191,85 +191,87 @@ export function ChatView({
})
return (
<div className={css.root} data-chat-following-tail={scroll.followingTail ? '' : undefined}>
<div ref={scroll.listRef} className={css.scroll}>
{scroll.initialized && (
<TurnNavigator
items={railItems}
activeTurn={scroll.activeTurn}
busyTurn={scroll.busyTurn}
onNavigate={scroll.navigateToTurn}
t={t}
/>
)}
<div ref={scroll.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}>
{t('chat.loadError', { message: openError.message, code: openError.code })}
</div>
)}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={scroll.loadEarlier}>
{loadingOlder ? t('loading') : t('chat.loadOlder')}
</button>
</div>
)}
<MarkdownDelegateProvider openExternalLink={openExternalLink} openFile={requestOpenFile}>
<ChatNodeList
entries={entries}
nodeStore={nodeStore}
useChatGroup={useChatGroup}
useChatNode={useChatNode}
useChatNodeProcess={useChatNodeProcess}
usePresentation={usePresentation}
useStore={useStore}
actions={actions}
cwd={cwd}
openFile={requestOpenFile}
openSkill={openSkill}
inspectCall={inspectCall}
forkAt={forkAt}
loadImage={loadImage}
renderMessageImages={renderMessageImages}
fileMentions={fileMentions}
renderSlot={renderSlot}
t={t}
/>
</MarkdownDelegateProvider>
{/* No pending placeholders: questions (ui-user-questions) and approvals
(ApprovalPanel) both take over the composer, so a flow card would
double-render the same wait. */}
{pendingInputs.map(item => 'requestId' in item ? (
<PendingSubmissionBubble
key={item.requestId}
submission={item}
renderMessageImages={renderMessageImages}
t={t}
/>
) : (
<PendingSteeringBubble
key={item.id}
content={item.content}
renderMessageImages={renderMessageImages}
t={t}
/>
))}
</div>
{!scroll.followingTail && (
<div className={css.toBottomSlot}>
<button
type="button"
className={css.toBottom}
aria-label={t('chat.toBottom')}
onClick={scroll.returnToBottom}
>
<IconChevronDownOutlineRegular />
</button>
<div className={css.frame}>
{scroll.initialized && (
<TurnNavigator
items={railItems}
activeTurn={scroll.activeTurn}
busyTurn={scroll.busyTurn}
onNavigate={scroll.navigateToTurn}
t={t}
/>
)}
<div className={css.root} data-chat-following-tail={scroll.followingTail ? '' : undefined}>
<div ref={scroll.listRef} className={css.scroll}>
<div ref={scroll.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}>
{t('chat.loadError', { message: openError.message, code: openError.code })}
</div>
)}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={scroll.loadEarlier}>
{loadingOlder ? t('loading') : t('chat.loadOlder')}
</button>
</div>
)}
<MarkdownDelegateProvider openExternalLink={openExternalLink} openFile={requestOpenFile}>
<ChatNodeList
entries={entries}
nodeStore={nodeStore}
useChatGroup={useChatGroup}
useChatNode={useChatNode}
useChatNodeProcess={useChatNodeProcess}
usePresentation={usePresentation}
useStore={useStore}
actions={actions}
cwd={cwd}
openFile={requestOpenFile}
openSkill={openSkill}
inspectCall={inspectCall}
forkAt={forkAt}
loadImage={loadImage}
renderMessageImages={renderMessageImages}
fileMentions={fileMentions}
renderSlot={renderSlot}
t={t}
/>
</MarkdownDelegateProvider>
{/* No pending placeholders: questions (ui-user-questions) and approvals
(ApprovalPanel) both take over the composer, so a flow card would
double-render the same wait. */}
{pendingInputs.map(item => 'requestId' in item ? (
<PendingSubmissionBubble
key={item.requestId}
submission={item}
renderMessageImages={renderMessageImages}
t={t}
/>
) : (
<PendingSteeringBubble
key={item.id}
content={item.content}
renderMessageImages={renderMessageImages}
t={t}
/>
))}
</div>
)}
</div>
</div>
{!scroll.followingTail && (
<div className={css.toBottomSlot}>
<button
type="button"
className={css.toBottom}
aria-label={t('chat.toBottom')}
onClick={scroll.returnToBottom}
>
<IconChevronDownOutlineRegular />
</button>
</div>
)}
{fileOpenError !== null && (
<FileOpenErrorDialog
message={fileOpenError.message}
@@ -1,8 +1,9 @@
/* Zero-height sticky slot, like the back-to-bottom control: the rail floats
over the transcript's right gutter without extending scrollHeight. */
/* The rail is a sibling of the clipped transcript, not its descendant. */
.slot {
position: sticky;
position: absolute;
top: 0;
right: 0;
left: 0;
/* Above transcript CodeBlock banners (z-index 6). The rail band excludes
the composer, so its preview never needs to cross the footer. */
z-index: 7;
@@ -10,6 +11,10 @@
pointer-events: none;
}
:global([data-conversation-scroll]) .slot {
position: sticky;
}
.frame {
/* The band a reader actually sees: the scrollport minus the sticky composer
stack covering its floor. ConversationRoot publishes both measurements on
@@ -22,9 +27,7 @@
position: absolute;
top: calc(var(--turn-rail-band) / 2);
/* Flush with the scrollport edge: the slot sits inside the transcript's side
padding, so the rail gives that inset back and keeps 12px of its own. */
right: calc(12px - (var(--dsh-composer-side-clearance) + 16px));
right: 12px;
width: 28px;
/* Fixed-pitch marks never compress: a ladder taller than the band scrolls
inside this frame instead of pushing marks under the composer or above
@@ -972,7 +972,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
const second = await view.findByRole('button', { name: '跳转到第 2 轮' })
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
const metrics = installScrollMetrics(scroller, 1_000, 300)
metrics.setLayout(1_000, 700)
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 0, bottom: 300 } as DOMRect)
@@ -1035,7 +1035,7 @@ describe('ChatView', () => {
] })
const view = render(<h.ChatView {...h.props} />)
const first = await view.findByRole('button', { name: '跳转到第 1 轮' })
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
installScrollMetrics(scroller, 1_500, 300)
const row = view.container.querySelector('[data-chat-anchor-key="fixture:user:1"]') as HTMLElement
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 800, 300))
@@ -1080,7 +1080,7 @@ describe('ChatView', () => {
expect(view.getByRole('button', { name: '回到底部' })).toBeTruthy()
// ...so a non-reader scroll delivery at the floor (the first prepend's
// compensation fires one) no longer snaps to the tail and cancel the jump.
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLElement
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLElement
fireEvent.scroll(scroller)
expect(first.getAttribute('aria-busy')).toBe('true')
await act(async () => { releaseJump?.() })
@@ -1149,7 +1149,7 @@ describe('ChatView', () => {
let releaseJump: (() => void) | undefined
h.loadThrough.mockImplementation(() => new Promise<void>((resolve) => { releaseJump = resolve }))
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
installScrollMetrics(scroller, 1_000, 300)
scroller.scrollTop = 700
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
@@ -1197,7 +1197,7 @@ describe('ChatView', () => {
{ hasMore: true },
)
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
const first = view.container.querySelector('[data-chat-flow-key="fixture:user:9"]') as HTMLDivElement
const next = view.container.querySelector('[data-chat-flow-key="fixture:user:10"]') as HTMLDivElement
let firstTop = 100
@@ -1256,7 +1256,7 @@ describe('ChatView', () => {
const pinned = anchor === 'group' ? group : old
const expectedTop = anchor === 'group' ? 30 : 60
const expectedScroll = anchor === 'group' ? 200 : 300
const scroller = view.container.querySelector<HTMLElement>('[class*="scroll"]')!
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement!
installScrollMetrics(scroller, 1500, 400)
let paged = false
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
@@ -1326,7 +1326,7 @@ describe('ChatView', () => {
h.setGrouped(groups)
h.setTranscriptView(mode)
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector<HTMLElement>('[class*="scroll"]')!
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement!
const column = scroller.querySelector<HTMLElement>('[data-chat-flow]')!
const answer = view.container.querySelector<HTMLElement>('[data-chat-node-key="fixture:assistant:6"]')!
const first = hasSteering ? view.container.querySelector<HTMLElement>('[data-chat-node-key="fixture:steering:5"]')! : answer
@@ -1380,7 +1380,7 @@ describe('ChatView', () => {
try {
const h = makeHarness({ nodes: [user(1, 'visible row')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
const anchor = view.container.querySelector('[data-chat-anchor-key="fixture:user:1"]') as HTMLElement
installScrollMetrics(scroller, 4_000, 2_000)
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({
@@ -1411,7 +1411,7 @@ describe('ChatView', () => {
{ hasMore: true },
)
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
const rows = [...view.container.querySelectorAll<HTMLElement>('[data-chat-flow-key]')]
let prepended = false
let rowRectCalls = 0
@@ -1622,7 +1622,7 @@ describe('ChatView', () => {
it('does not pull the reader back to the tail when a local steer becomes Host-pending', () => {
const h = makeHarness({ nodes: [assistant(1, 'working')] }, { running: true })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector<HTMLElement>('[class*="scroll"]')!
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement!
installScrollMetrics(scroller, 1_000, 300)
act(() => { h.setSession({ pendingSubmissions: [{
requestId: 'req-steer' as never, placement: 'steering', time: 5_000, text: 'continue here', attachments: [],
@@ -2307,7 +2307,7 @@ describe('ChatView', () => {
turnTimings: new Map([[1, { startTime: 0 }]]),
})
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
const firstRow = view.getByText('first answer').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
@@ -2331,7 +2331,7 @@ describe('ChatView', () => {
})
const view = render(<h.ChatView {...h.props} />)
expect(turnProcessControl(view.container)).toBeNull()
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
const processRow = view.container.querySelector<HTMLElement>('[data-chat-node-key="fixture:assistant:2"]')!
@@ -3199,7 +3199,7 @@ describe('ChatView', () => {
{ hasMore: true },
)
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement 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 })
@@ -3229,7 +3229,7 @@ describe('ChatView', () => {
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
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
readerScroll(scroller, 50)
@@ -3244,7 +3244,7 @@ describe('ChatView', () => {
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} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
readerScroll(scroller, 100) // far from bottom
@@ -3266,7 +3266,7 @@ describe('ChatView', () => {
it('keeps following when a stream-finalization shrink clamp delivers its scroll', () => {
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
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
const metrics = installScrollMetrics(scroller, 1_000, 300)
scroller.scrollTop = 700
fireEvent.scroll(scroller)
@@ -3289,7 +3289,7 @@ describe('ChatView', () => {
it('keeps following when a shrink clamp regrows before scrollend', () => {
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
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
const metrics = installScrollMetrics(scroller, 1_000, 300)
scroller.scrollTop = 700
fireEvent.scroll(scroller)
@@ -3319,7 +3319,7 @@ describe('ChatView', () => {
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
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
const metrics = installScrollMetrics(scroller, 9_931, 300)
expect(notify).toBeDefined()
scroller.scrollTop = 9_631
@@ -3357,7 +3357,7 @@ describe('ChatView', () => {
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
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
const metrics = installScrollMetrics(scroller, 1_000, 300)
expect(notify).toBeDefined()
scroller.scrollTop = 700
@@ -3380,7 +3380,7 @@ describe('ChatView', () => {
it('keeps following when reader input reaches the floor before growth and scrollend', () => {
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
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
const metrics = installScrollMetrics(scroller, 1_000, 300)
readerScroll(scroller, 100)
expect(view.getByLabelText('回到底部')).toBeTruthy()
@@ -3400,7 +3400,7 @@ describe('ChatView', () => {
const nodes = [user(1, 'q'), assistant(2, 'a')]
const h = makeHarness({ nodes })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
const metrics = installScrollMetrics(scroller, 1_000, 300)
readerScroll(scroller, 700)
scroller.scrollTop = 650
@@ -3431,7 +3431,7 @@ describe('ChatView', () => {
it('clears an away sample when a back-to-bottom delivery restores pinned ownership', () => {
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
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
const metrics = installScrollMetrics(scroller, 1_000, 300)
scroller.scrollTop = 700
fireEvent.scroll(scroller)
@@ -3453,7 +3453,7 @@ describe('ChatView', () => {
try {
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
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
installScrollMetrics(scroller, 1_000, 300)
scroller.scrollTop = 700
fireEvent.scroll(scroller)
@@ -3496,7 +3496,7 @@ describe('ChatView', () => {
it('uses the last delivered top when compositor scrolling precedes scroll delivery', () => {
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
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
installScrollMetrics(scroller, 1_000, 300)
scroller.scrollTop = 700
fireEvent.scroll(scroller)
@@ -3524,7 +3524,7 @@ describe('ChatView', () => {
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
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
scroller.scrollTop = 700
@@ -3578,7 +3578,7 @@ describe('ChatView', () => {
})
const view = render(<h.ChatView {...h.props} />)
await view.findByRole('button', { name: '跳转到第 2 轮' })
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
const metrics = installScrollMetrics(scroller, 1_000, 300)
scroller.scrollTop = 700
act(() => {
@@ -3604,7 +3604,7 @@ describe('ChatView', () => {
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} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
// Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome