Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';

import {
consumePendingReanchorForAutoFollow,
pickIntersectingChildAnchor,
readAnchorClientId,
readViewportChildAnchorClientId,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(() => {',
Expand Down
33 changes: 31 additions & 2 deletions apps/desktop/src/renderer/components/chat/MessageStream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down