fix(ui-chat): retain fold surplus as bottom space
This commit is contained in:
1 parent
9db5308d9b
commit
37d9dadd4e
8 files changed
+58
-60
No files matched your search
@@ -557,13 +557,17 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
const beforeHeight = await page.locator('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')
|
||||
.evaluate(element => element.getBoundingClientRect().height)
|
||||
const release = holdTextAfter(world, 0)
|
||||
const trace = page.evaluate((marker) => new Promise<{
|
||||
readonly t: number; readonly top: number; readonly messageY: number;
|
||||
readonly columnHeight: number; readonly folding: boolean;
|
||||
}[]>(resolve => {
|
||||
const trace = page.evaluate(marker => new Promise<{
|
||||
readonly t: number
|
||||
readonly top: number
|
||||
readonly messageY: number
|
||||
readonly columnHeight: number
|
||||
readonly spacer: number
|
||||
readonly folding: boolean
|
||||
}[]>((resolve) => {
|
||||
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')!
|
||||
const column = host.querySelector<HTMLElement>('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')!
|
||||
const samples: { t: number; top: number; messageY: number; columnHeight: number; folding: boolean }[] = []
|
||||
const samples: { t: number; top: number; messageY: number; columnHeight: number; spacer: number; folding: boolean }[] = []
|
||||
let frame = 0
|
||||
let timer = 0
|
||||
const sample = (): void => {
|
||||
@@ -572,6 +576,7 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
if (message === undefined) return
|
||||
samples.push({ t: performance.now(), top: host.scrollTop, messageY: message.getBoundingClientRect().top,
|
||||
columnHeight: column.getBoundingClientRect().height,
|
||||
spacer: Number.parseFloat(host.querySelector<HTMLElement>('[data-chat-turn-spacer]')?.style.height ?? '') || 0,
|
||||
folding: column.querySelector('[data-chat-motion="collapse"]') !== null })
|
||||
}
|
||||
const tick = (): void => {
|
||||
@@ -607,17 +612,24 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
const first = samples[0]!
|
||||
let minimum = first.messageY
|
||||
let reversal = 0
|
||||
let maximumScroll = first.top
|
||||
let viewportBacktrack = 0
|
||||
for (const sample of samples) {
|
||||
minimum = Math.min(minimum, sample.messageY)
|
||||
if (first.messageY - minimum > 4) reversal = Math.max(reversal, sample.messageY - minimum)
|
||||
maximumScroll = Math.max(maximumScroll, sample.top)
|
||||
viewportBacktrack = Math.max(viewportBacktrack, maximumScroll - sample.top)
|
||||
}
|
||||
const afterHeight = samples.at(-1)!.columnHeight
|
||||
await writeFile(join(evidence, 'motion.json'), JSON.stringify({ beforeHeight, afterHeight, reversal, samples }, null, 2))
|
||||
const last = samples.at(-1)!
|
||||
const afterHeight = last.columnHeight
|
||||
await writeFile(join(evidence, 'motion.json'), JSON.stringify({ beforeHeight, afterHeight, reversal, viewportBacktrack, samples }, null, 2))
|
||||
await page.screenshot({ path: join(evidence, 'settled.png') })
|
||||
console.log(`fold-input e2e: reversal=${reversal}px, samples=${samples.length}, evidence=${evidence}`)
|
||||
console.log(`fold-input e2e: message reversal=${reversal}px, viewport backtrack=${viewportBacktrack}px, spacer=${last.spacer}px, evidence=${evidence}`)
|
||||
expect(beforeHeight - afterHeight).toBeGreaterThan(250)
|
||||
expect(first.messageY - minimum).toBeGreaterThan(40)
|
||||
expect(reversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
expect(viewportBacktrack).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
expect(Math.abs(last.spacer - (beforeHeight - afterHeight))).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
await expectBottom(page)
|
||||
assertClean(world)
|
||||
})
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
en: 8b2586e6b9f188a7
|
||||
zh: 6687f9e67432d04f
|
||||
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
|
||||
en: 7a0dfe154ee330f3
|
||||
zh: 95a14dd8c56ee6f3
|
||||
en: 838659819e09413d
|
||||
zh: 43c754f34c193fff
|
||||
/deepseek-ai-dsh-client-ui-chat/model-experience:
|
||||
en: c17b4dd43a61c569
|
||||
zh: f7414309810c3a08
|
||||
|
||||
@@ -128,7 +128,7 @@ The transcript root uses `overflow-x: visible; overflow-y: clip`: vertical overf
|
||||
|
||||
Outer transcript following and each open capped group's following are independent. Native animation progress retains follow intent; only that scrollport's own `scrollend` settles its animation. Browsers without `scrollend` may retain the native follow target until a later bottom-aligned update or reader gesture. A reader gesture interrupts the animation, and actual movement determines whether following remains enabled. Scroll chaining can move the outer transcript, which then applies its own distance threshold. The back-to-bottom button restores only outer following.
|
||||
|
||||
Next-message folding combines row closure and tail following in one cancellable task, with browser scroll anchoring disabled during the fold. Reserved bottom space preserves the physical scroll range. Before paint, the task limits fold-driven movement to the remaining tail distance; after the rows close, native smooth scrolling covers only any remaining movement toward newer content. The content does not overshoot and then scroll back toward the composer. Reserved space is released on arrival without a fixed continuation speed. Reader gestures cancel the task and active native following; resize cannot revive a cancelled task in the same fold, but new submitted input can. Off-bottom readers retain their position and the space needed to prevent clamping. Unloading Chat cancels animation frames, waiting callbacks, and row transitions. Reduced-motion users receive immediate visibility and positioning changes.
|
||||
Next-message folding combines row closure and tail following in one cancellable task, with browser scroll anchoring disabled during the fold. If the removed height exceeds the new content's height, the difference remains as bottom space rather than reducing the scroll offset to chase the unpadded content floor. This applies even when the conversation already exceeds the viewport. New content fills the retained space before following advances again; a smaller fold leaves only the remaining forward distance to scroll. Reader gestures cancel the task and active native following; resize cannot revive a cancelled task in the same fold, but new submitted input can. Off-bottom readers retain their position, and acknowledged scrolling releases only space that can be removed without clamping them. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes.
|
||||
|
||||
Fold coordination is viewport-local: reservations and idle callbacks never broadcast to other Chat views. ChatView supplies its motion capability once to `conversation.chat.flow` through the render's `hookContext`. Flow binds the group and header behavior hooks, owns node and image rendering, and shares the existing Chat store and Session node sources. Layout nodes and inline reasoning receive the bound hooks without receiving the motion controller or changing content parents. Generic searchable hiding applies before paint and owns focus protection and beforematch handling; pending-input reconciliation remains in ChatView.
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
|
||||
|
||||
外层文本记录与每个已展开限高组的跟随状态彼此独立。原生动画的中间滚动保留跟随意图;只有该滚动容器自身的 `scrollend` 才结束其动画。不支持 `scrollend` 的浏览器可能保留原生跟随目标,直到后续贴底更新或读者手势将其结束。读者手势中断动画,是否继续跟随由实际位移决定。滚动传递可以带动外层,外层再按自身离底距离判断。回到底部按钮只恢复外层跟随。
|
||||
|
||||
下次消息触发的折叠与跟尾属于同一个可取消任务,折叠期间禁用浏览器滚动锚定。底部预留空间保留物理滚动范围。任务在绘制前将折叠带来的位移限制在剩余跟尾距离内;过程行收起后,原生平滑滚动仅完成仍未覆盖的朝向新内容的位移,不让内容先越过目标再向 composer 回滚。到达后释放预留空间,不保证固定续滚速度。读者手势取消该任务及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复已取消的任务,但新提交的输入可以。离底读者保留其位置及防止滚动钳制所需的空间。卸载 Chat 时取消动画帧、等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。
|
||||
下次消息触发的折叠与跟尾属于同一个可取消任务,折叠期间禁用浏览器滚动锚定。如果移除的高度大于新增内容的高度,差值保留为底部空间,不通过减小滚动偏移追到未含预留空间的内容底部;对话总高度已经超过视口时也遵循此规则。后续新增内容先填充保留区域,填满后才继续跟尾;折叠高度较小时,仅滚动剩余的向前距离。读者手势取消该任务及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复已取消的任务,但新提交的输入可以。离底读者保留位置,已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。
|
||||
|
||||
折叠协调状态由各自的 viewport 持有,空间预留和空闲回调不会广播到其他 Chat 视图。ChatView 通过本次渲染的 `hookContext`,向 `conversation.chat.flow` 交接一次动画能力。Flow 绑定组内容与组头的行为 hook,负责节点和图片渲染,并共享既有 Chat store 与 Session 节点来源。布局节点和行内思考接收绑定后的 hook,不接收动画控制器,也不改变内容的父级。通用的可搜索隐藏在绘制前生效,并负责焦点保护及 beforematch;pending 输入对账仍留在 ChatView。
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
const content = useRef<{ input: ChatScrollInput; applied: ChatScrollInput | null; opened: boolean }>({
|
||||
input, applied: null, opened: false,
|
||||
})
|
||||
// Fold movement consumes the same tail distance as scrolling. It cannot overshoot that distance;
|
||||
// after the rows close, native following covers only the remaining forward movement.
|
||||
// A fold larger than the new content leaves bottom room rather than reversing the scrollport.
|
||||
// Growth consumes that room before automatic following advances toward newer content.
|
||||
const cancelFollow = useRef<(() => void) | null>(null)
|
||||
const clearPendingFollow = useCallback(() => {
|
||||
cancelFollow.current?.()
|
||||
@@ -69,22 +69,11 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
const followAfterFold = useCallback(() => {
|
||||
if (cancelFollow.current !== null) return
|
||||
reading.interruptFollow()
|
||||
const advance = (): void => {
|
||||
viewport.limitFoldDisplacement()
|
||||
frame = requestAnimationFrame(advance)
|
||||
}
|
||||
let frame = requestAnimationFrame(advance)
|
||||
const disconnect = viewport.motion.onFoldIdle(() => {
|
||||
cancelAnimationFrame(frame)
|
||||
cancelFollow.current = viewport.motion.onFoldIdle(() => {
|
||||
cancelFollow.current = null
|
||||
viewport.limitFoldDisplacement()
|
||||
viewport.reclaimBelow()
|
||||
if (content.current.input.deferCompletedTurns) reading.followTail('smooth')
|
||||
})
|
||||
cancelFollow.current = () => {
|
||||
cancelAnimationFrame(frame)
|
||||
disconnect()
|
||||
}
|
||||
}, [reading, viewport])
|
||||
|
||||
const processContent = useCallback(() => {
|
||||
|
||||
@@ -160,17 +160,9 @@ export class ChatViewport {
|
||||
this.setSpacer(this.spacerHeight() + px)
|
||||
}
|
||||
|
||||
/** Limit an automatic fold's visible movement to the remaining tail distance before paint. */
|
||||
limitFoldDisplacement(): void {
|
||||
const metrics = this.metrics()
|
||||
if (metrics === null) return
|
||||
const floor = Math.max(0, metrics.floor - this.spacerHeight())
|
||||
if (metrics.top > floor) this.write(floor, metrics, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Give reserved room back after acknowledged scrolling or content growth without clamping the reader.
|
||||
* Holds still during folds and unsampled movement; tail following targets content without this room.
|
||||
* Holds still during folds and unsampled movement; following includes the room that remains.
|
||||
*/
|
||||
reclaimBelow(): void {
|
||||
const elements = this.elements
|
||||
@@ -180,7 +172,7 @@ export class ChatViewport {
|
||||
const scroll = this.readScroll()
|
||||
// Trimming the range before sampling could misclassify a reader's move as a layout clamp.
|
||||
if (scroll === null || scroll.movedByReader) return
|
||||
const floorWithoutSpacer = scroll.metrics.floor
|
||||
const floorWithoutSpacer = scroll.metrics.floor - current
|
||||
this.setSpacer(Math.min(current, Math.max(0, scroll.metrics.top - floorWithoutSpacer)))
|
||||
}
|
||||
|
||||
@@ -213,15 +205,11 @@ export class ChatViewport {
|
||||
const metrics = this.metrics()
|
||||
if (metrics === null) return null
|
||||
return {
|
||||
metrics: { ...metrics, floor: this.contentFloor(metrics) },
|
||||
metrics,
|
||||
movedByReader: Math.abs(metrics.top - Math.min(this.observation.top, metrics.floor)) > 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
private contentFloor(metrics: ViewportMetrics): number {
|
||||
return this.motion.foldActive() ? metrics.floor : Math.max(0, metrics.floor - this.spacerHeight())
|
||||
}
|
||||
|
||||
private metrics(): ViewportMetrics | null {
|
||||
const scroller = this.elements?.scroller
|
||||
if (scroller === undefined) return null
|
||||
@@ -461,7 +449,7 @@ export class ChatViewport {
|
||||
}
|
||||
|
||||
/**
|
||||
* Align with the content floor, excluding reserved fold space once rows finish closing.
|
||||
* Align with the physical floor, including fold room that newer content has not yet filled.
|
||||
* @param follow - independent follow intent and scrolling controller.
|
||||
* @param behavior - immediate positioning, or one native animation for a reader-caused tail change.
|
||||
* @returns the actual floor landing, or null while detached. A smooth landing reports the starting
|
||||
@@ -470,13 +458,14 @@ export class ChatViewport {
|
||||
scrollToBottom(follow: ScrollFollow, behavior: 'instant' | 'smooth' = 'instant'): ViewportLanding | null {
|
||||
const metrics = this.metrics()
|
||||
if (metrics === null || this.elements === null) return null
|
||||
this.observation = { top: metrics.top, landing: null }
|
||||
this.reclaimBelow()
|
||||
const landing: ViewportLanding = {
|
||||
metrics: follow.toBottom(this.elements.scroller, { ...metrics, floor: this.contentFloor(metrics) }, behavior),
|
||||
metrics: follow.toBottom(this.elements.scroller, scrollMetrics(this.elements.scroller), behavior),
|
||||
position: null,
|
||||
turn: this.latestTurn,
|
||||
}
|
||||
this.observation = { top: landing.metrics.top, landing }
|
||||
this.reclaimBelow()
|
||||
return landing
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('Chat scroll collapse timing', () => {
|
||||
expect(h.scroller.style.overflowAnchor).toBe('')
|
||||
})
|
||||
|
||||
it('consumes excess collapse during the fold without a reverse native follow afterward', async () => {
|
||||
it('keeps excess collapse as bottom room without reversing the scrollport', async () => {
|
||||
const h = mountScroll()
|
||||
h.scroller.scrollTop = 600
|
||||
h.startFold()
|
||||
@@ -147,13 +147,13 @@ describe('Chat scroll collapse timing', () => {
|
||||
expect(h.scroller.scrollTop).toBe(600)
|
||||
h.grow(980)
|
||||
act(() => { vi.advanceTimersByTime(20) })
|
||||
expect(h.scroller.scrollTop).toBe(580)
|
||||
expect(h.scroller.scrollTop).toBe(600)
|
||||
fireEvent.scroll(h.scroller)
|
||||
expect(h.scroller.scrollTop).toBe(580)
|
||||
expect(h.scroller.scrollTop).toBe(600)
|
||||
h.finishFold()
|
||||
expect(h.scroller.scrollTop).toBe(580)
|
||||
expect(h.scroller.scrollTop).toBe(600)
|
||||
expect(h.scrollTo).not.toHaveBeenCalled()
|
||||
expect(h.scroller.querySelector<HTMLElement>('[data-chat-turn-spacer]')?.style.height).toBe('')
|
||||
expect(h.scroller.querySelector<HTMLElement>('[data-chat-turn-spacer]')?.style.height).toBe('20px')
|
||||
})
|
||||
|
||||
it('follows only the distance left after a smaller fold', async () => {
|
||||
|
||||
@@ -67,6 +67,7 @@ function fixture() {
|
||||
})
|
||||
return {
|
||||
list, spacer, row, viewport, follow, reading, scrollTo,
|
||||
grow: (height: number) => { contentHeight = height },
|
||||
close: async () => {
|
||||
viewport.motion.collapse(row, () => { row.hidden = true })
|
||||
await Promise.resolve()
|
||||
@@ -83,20 +84,27 @@ function fixture() {
|
||||
}
|
||||
}
|
||||
|
||||
it('follows the real content floor after a large close without dropping reserved space ahead of motion', async () => {
|
||||
it('retains fold room and lets content growth consume it without reversing the scrollport', async () => {
|
||||
const h = fixture()
|
||||
await h.close()
|
||||
h.viewport.reclaimBelow()
|
||||
expect(h.list.scrollTop).toBe(2_000)
|
||||
h.reading.followTail('smooth')
|
||||
expect(h.scrollTo).toHaveBeenCalledExactlyOnceWith({ top: 200, behavior: 'smooth' })
|
||||
expect(h.list.scrollTop).toBe(2_000)
|
||||
h.scroll(1_000)
|
||||
expect(h.reading.followingTail).toBe(true)
|
||||
h.scroll(200)
|
||||
h.list.dispatchEvent(new Event('scrollend'))
|
||||
expect(h.spacer.style.height).toBe('1800px')
|
||||
expect(h.scrollTo).not.toHaveBeenCalled()
|
||||
h.grow(1_000)
|
||||
h.viewport.reclaimBelow()
|
||||
h.reading.onResize()
|
||||
expect(h.list.scrollTop).toBe(2_000)
|
||||
expect(h.spacer.style.height).toBe('1400px')
|
||||
h.grow(2_400)
|
||||
h.viewport.reclaimBelow()
|
||||
h.reading.onResize()
|
||||
expect(h.list.scrollTop).toBe(2_000)
|
||||
expect(h.spacer.style.height).toBe('')
|
||||
expect(h.list.scrollTop).toBe(200)
|
||||
h.grow(2_520)
|
||||
h.reading.onResize()
|
||||
expect(h.list.scrollTop).toBe(2_120)
|
||||
expect(h.follow.animating).toBe(false)
|
||||
})
|
||||
|
||||
@@ -117,13 +125,13 @@ it('keeps manual off-bottom ownership and reclaims only space below the reader',
|
||||
expect(h.follow.animating).toBe(false)
|
||||
})
|
||||
|
||||
it('releases fold room immediately for reduced-motion following', async () => {
|
||||
it('retains existing fold room when reduced-motion following has no forward distance', async () => {
|
||||
const h = fixture()
|
||||
await h.close()
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true })))
|
||||
h.reading.followTail('smooth')
|
||||
expect(h.list.scrollTop).toBe(200)
|
||||
expect(h.spacer.style.height).toBe('')
|
||||
expect(h.list.scrollTop).toBe(2_000)
|
||||
expect(h.spacer.style.height).toBe('1800px')
|
||||
expect(h.follow.animating).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user