fix(chat): keep floating controls outside transcript clipping
This commit is contained in:
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,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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user