diff --git a/apps/desktop/src/renderer/__tests__/focusScrollLifecycle.test.ts b/apps/desktop/src/renderer/__tests__/focusScrollLifecycle.test.ts index b9efef605ce..c142757dfbd 100644 --- a/apps/desktop/src/renderer/__tests__/focusScrollLifecycle.test.ts +++ b/apps/desktop/src/renderer/__tests__/focusScrollLifecycle.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { + consumePendingReanchorForAutoFollow, pickIntersectingChildAnchor, readAnchorClientId, readViewportChildAnchorClientId, @@ -61,6 +62,36 @@ describe('focus scroll cancellation decisions', () => { }); }); +describe('render-item compensation priority', () => { + it('consumes pending reanchor and short-circuits old compensation while following the tail', () => { + let pendingReanchor = true; + + const shouldShortCircuit = consumePendingReanchorForAutoFollow({ + isNearBottom: true, + clearPendingReanchor: () => { + pendingReanchor = false; + }, + }); + + expect(shouldShortCircuit).toBe(true); + expect(pendingReanchor).toBe(false); + }); + + it('preserves pending reanchor and continues compensation after the user scrolls up', () => { + let clearCount = 0; + + const shouldShortCircuit = consumePendingReanchorForAutoFollow({ + isNearBottom: false, + clearPendingReanchor: () => { + clearCount += 1; + }, + }); + + expect(shouldShortCircuit).toBe(false); + expect(clearCount).toBe(0); + }); +}); + describe('chip jump settlement', () => { it('recomputes the landing from settled target geometry after content above it is deleted', () => { expect( @@ -425,6 +456,26 @@ describe('MessageStream focus cancellation wiring', () => { expect(compensation).not.toContain('if (anchor.messageClientId && snapshotMessageGone) {'); }); + it('uses the auto-follow decision as a hard return before viewport compensation', () => { + const compensation = sourceBetween( + '// ── 删除靠前 message 后的视口保位(#2289)──', + '// ── post-load auto-expand ──', + ); + const guardStart = compensation.indexOf('consumePendingReanchorForAutoFollow({'); + const guardEnd = compensation.indexOf( + 'const snapshot = lastViewportTopRef.current;', + guardStart, + ); + expect(guardStart).toBeGreaterThanOrEqual(0); + expect(guardEnd).toBeGreaterThan(guardStart); + const guard = compensation.slice(guardStart, guardEnd); + + expect(guard).toContain('isNearBottom: isNearBottomRef.current'); + expect(guard).toContain('pendingReanchorScrollRef.current = null;'); + expect(guard).toMatch(/\)\s*\{\s*return;\s*\}/); + expect(compensation).not.toContain('if (isNearBottomRef.current) return;'); + }); + it('finishes an older chip or rail navigation before starting a jump to bottom', () => { const jumpToBottom = sourceBetween( 'const scrollToBottomSmooth = useCallback(() => {', diff --git a/apps/desktop/src/renderer/components/chat/MessageStream.tsx b/apps/desktop/src/renderer/components/chat/MessageStream.tsx index 1ed43a2b5f4..a1744a733d8 100644 --- a/apps/desktop/src/renderer/components/chat/MessageStream.tsx +++ b/apps/desktop/src/renderer/components/chat/MessageStream.tsx @@ -125,6 +125,22 @@ export function resolveProgrammaticScrollEndDecision({ return consumeDeferredDelete ? 'consume-deferred-delete' : 'replay-deferred-delete'; } +/** + * 贴底时由 auto-follow 独占本轮布局变化,并消费此前记录的视口重锚。 + * 返回 true 表示调用方必须立即结束旧视口补偿,避免其覆盖同一提交里的 pinToBottom。 + */ +export function consumePendingReanchorForAutoFollow({ + isNearBottom, + clearPendingReanchor, +}: { + isNearBottom: boolean; + clearPendingReanchor: () => void; +}): boolean { + if (!isNearBottom) return false; + clearPendingReanchor(); + return true; +} + /** 以落定时的最新 DOM 几何重新计算 chip / 导航轨道目标,而不是复用 smooth 开始前的像素。 */ export function resolveChipJumpTargetScrollTop({ scrollTop, @@ -5132,6 +5148,21 @@ export function MessageStream({ const prevAllItems = prevAllItemsRef.current; prevVisibleItemsRef.current = visibleRenderItems; prevAllItemsRef.current = allRenderItems; + + // #3067: turn 完成会把运行中工作组重建为完成态分组,key / 数量都可能变化。 + // 贴底态必须始终由 auto-follow 接管;若先执行旧锚点恢复,会覆盖上方同一提交里的 + // pinToBottom,把视口拉回本轮 user 消息。同步消费待重锚,避免用户稍后上滚时重放旧落点。 + if ( + consumePendingReanchorForAutoFollow({ + isNearBottom: isNearBottomRef.current, + clearPendingReanchor: () => { + pendingReanchorScrollRef.current = null; + }, + }) + ) { + return; + } + const snapshot = lastViewportTopRef.current; const prevSeq = prevAllItems.length > 0 ? prevAllItems : prevVisibleItems; @@ -5191,8 +5222,6 @@ export function MessageStream({ if (programmaticScrollRef.current) deferredDeleteCompensationRef.current = true; return; } - if (isNearBottomRef.current) return; - let anchor = snapshot; if (restoringRef.current) { const snap = restoreSnapshotRef.current;