修复:会话滚动锚点改用稳定块身份定位

锚点从「回合 key + 块序号」改为「回合 key + 块身份」,findTurnBlock 按 data-block-key 定位:回合收口时过程块被折进 details,块序号会整体后移,原实现会解析到隔壁块并按错误基准写 scrollTop。

收集选择器收敛为 [data-turn-key][data-block-key],只把可定位的块计入候选。

锚点块没有布局盒(被折进收起的 details)时读锚点跳过它、restoreTurnAnchor 判失败并放弃这次补偿,调用方拿当前位置重新起锚。

用例补齐:块序号后移后按块身份仍还原到同一块、收起的 details 里的块不参与读取也不参与还原、只收集带块身份的块。

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 20:18:08 +08:00
parent 42de1b4c39
commit 429557ea6a
3 changed files with 188 additions and 50 deletions
@@ -12,6 +12,9 @@ import {
type StubRect = { top: number; bottom: number };
/** 一个锚点候选块:回合 key 与稳定块身份,对应 DOM 上的 `data-turn-key` / `data-block-key`。 */
type BlockSpec = readonly [turnKey: string, blockKey: string];
/** jsdom 不做布局:每个块的屏幕位置都由用例显式给出。 */
function stubRect(element: HTMLElement, rect: StubRect): void {
element.getBoundingClientRect = () =>
@@ -29,14 +32,15 @@ function stubRect(element: HTMLElement, rect: StubRect): void {
}
function buildList(
keys: readonly string[],
specs: readonly BlockSpec[],
listRect: StubRect = { top: 0, bottom: 400 },
) {
document.body.innerHTML = '';
const list = document.createElement('div');
for (const key of keys) {
for (const [turnKey, blockKey] of specs) {
const block = document.createElement('div');
block.dataset.turnKey = key;
block.dataset.turnKey = turnKey;
block.dataset.blockKey = blockKey;
list.appendChild(block);
}
document.body.appendChild(list);
@@ -49,13 +53,30 @@ afterEach(() => {
});
describe('collectTurnBlocks', () => {
it('按文档顺序收集所有带回合身份的块', () => {
const { list } = buildList(['t1', 't2']);
it('按文档顺序收集所有可锚定的块', () => {
const { list } = buildList([
['t1', 'b1'],
['t2', 'b2'],
]);
expect(collectTurnBlocks(list)).toHaveLength(2);
});
it('只收集同时带回合 key 与块身份的块', () => {
const { list } = buildList([['t1', 'b1']]);
// 有回合 key、没有块身份:定位不到它,读锚点时也会被跳过,干脆不进候选集合。
const keyOnly = document.createElement('div');
keyOnly.dataset.turnKey = 't1';
list.appendChild(keyOnly);
invalidateTurnBlocks(list);
expect(collectTurnBlocks(list)).toHaveLength(1);
});
it('同一列表连续收集只查一次 DOM', () => {
const { list } = buildList(['t1', 't2']);
const { list } = buildList([
['t1', 'b1'],
['t2', 'b2'],
]);
collectTurnBlocks(list);
const query = vi.spyOn(list, 'querySelectorAll');
@@ -67,10 +88,11 @@ describe('collectTurnBlocks', () => {
});
it('子元素变化并失效后重新收集', () => {
const { list } = buildList(['t1']);
const { list } = buildList([['t1', 'b1']]);
const before = collectTurnBlocks(list);
const extra = document.createElement('div');
extra.dataset.turnKey = 't2';
extra.dataset.blockKey = 'b2';
list.appendChild(extra);
invalidateTurnBlocks(list);
@@ -83,7 +105,11 @@ describe('collectTurnBlocks', () => {
describe('readTopVisibleTurnAnchor', () => {
it('跳过完全滚出顶部的块,取第一条真正露出的块', () => {
const { list, blocks } = buildList(['t1', 't2', 't3']);
const { list, blocks } = buildList([
['t1', 'b1'],
['t2', 'b2'],
['t3', 'b3'],
]);
stubRect(blocks[0]!, { top: -120, bottom: -100 });
stubRect(blocks[1]!, { top: -10, bottom: 30 });
stubRect(blocks[2]!, { top: 40, bottom: 90 });
@@ -91,58 +117,156 @@ describe('readTopVisibleTurnAnchor', () => {
// 半条消息也算"正在读的位置":锚点必须是它,拿下一整条会把半条顶出视口。
expect(readTopVisibleTurnAnchor(list)).toEqual({
key: 't2',
index: 0,
blockKey: 'b2',
offset: -10,
});
});
it('同一个回合的第二个块露出时序号是 1', () => {
const { list, blocks } = buildList(['t1', 't2', 't2']);
it('同一个回合的第二个块露出时取的是它自己的块身份', () => {
const { list, blocks } = buildList([
['t1', 'b1'],
['t2', 'b2'],
['t2', 'b3'],
]);
stubRect(blocks[0]!, { top: -200, bottom: -150 });
stubRect(blocks[1]!, { top: -140, bottom: -60 });
stubRect(blocks[2]!, { top: -20, bottom: 40 });
expect(readTopVisibleTurnAnchor(list)).toEqual({
key: 't2',
index: 1,
blockKey: 'b3',
offset: -20,
});
});
it('收起的 details 里的块没有布局盒:不拿它当锚点', () => {
const { list, blocks } = buildList([
['t2', 'process'],
['t2', 'g1'],
]);
stubRect(blocks[0]!, { top: 100, bottom: 130 });
// 被折进收起 `<details>` 的块在真实浏览器里矩形全 0:它既不是用户正在读的位置,
// 也当不了还原基准。
stubRect(blocks[1]!, { top: 0, bottom: 0 });
expect(readTopVisibleTurnAnchor(list)).toEqual({
key: 't2',
blockKey: 'process',
offset: 100,
});
});
it('列表里没有块露出视口时没有锚点', () => {
const { list, blocks } = buildList(['t1']);
const { list, blocks } = buildList([['t1', 'b1']]);
stubRect(blocks[0]!, { top: -50, bottom: -10 });
expect(readTopVisibleTurnAnchor(list)).toBeNull();
});
});
describe('findTurnBlock', () => {
it('按回合 key 与块序号定位', () => {
const { list, blocks } = buildList(['t1', 't2', 't2']);
expect(findTurnBlock(list, 't2', 0)).toBe(blocks[1]);
expect(findTurnBlock(list, 't2', 1)).toBe(blocks[2]);
expect(findTurnBlock(list, 't2', 2)).toBeNull();
it('按回合 key 与块身份定位', () => {
const { list, blocks } = buildList([
['t1', 'b1'],
['t2', 'b2'],
['t2', 'b3'],
]);
expect(findTurnBlock(list, 't2', 'b3')).toBe(blocks[2]);
expect(findTurnBlock(list, 't2', 'missing')).toBeNull();
expect(findTurnBlock(list, 'missing', 'b2')).toBeNull();
});
it('块序号变了不影响定位:收口把过程折进 details,序号整体后移', () => {
const finished = buildList([
['t2', 'u'],
['t2', 'process'],
['t2', 'g1'],
['t2', 'g2'],
['t2', 'f'],
['t2', 'usage'],
]);
// 按序号定位的实现会拿第 2 个块当 g2,而收口后它已经是 g1。
expect(finished.blocks[2]!.dataset.blockKey).toBe('g1');
expect(findTurnBlock(finished.list, 't2', 'g2')).toBe(finished.blocks[3]);
});
});
describe('restoreTurnAnchor', () => {
it('按块当前位置差改 scrollTop,让锚点回到原来的偏移', () => {
const { list, blocks } = buildList(['t1', 't2']);
const { list, blocks } = buildList([
['t1', 'b1'],
['t2', 'b2'],
]);
// 更早历史插进来之后,原本贴在视口顶边的 t2 被顶到下方 90px。
stubRect(blocks[1]!, { top: 90, bottom: 140 });
list.scrollTop = 0;
const anchor: ConversationTurnAnchor = { key: 't2', index: 0, offset: -10 };
const anchor: ConversationTurnAnchor = {
key: 't2',
blockKey: 'b2',
offset: -10,
};
expect(restoreTurnAnchor(list, anchor)).toBe(true);
expect(list.scrollTop).toBe(100);
});
it('锚点对应的块已经不在了:返回 false,不动 scrollTop', () => {
const { list } = buildList(['t1']);
it('锚点块被折进收起的 details(没有布局盒):返回 false,不动 scrollTop', () => {
const { list, blocks } = buildList([
['t2', 'u'],
['t2', 'process'],
['t2', 'g1'],
]);
stubRect(blocks[0]!, { top: -200, bottom: -150 });
stubRect(blocks[1]!, { top: -100, bottom: -70 });
stubRect(blocks[2]!, { top: 0, bottom: 0 });
list.scrollTop = 30;
expect(
restoreTurnAnchor(list, { key: 'missing', index: 0, offset: -10 }),
restoreTurnAnchor(list, { key: 't2', blockKey: 'g1', offset: -10 }),
).toBe(false);
expect(list.scrollTop).toBe(30);
});
it('回合收口后按块身份仍还原到同一个块', () => {
const running = buildList([
['t2', 'u'],
['t2', 'g1'],
['t2', 'g2'],
]);
stubRect(running.blocks[0]!, { top: -200, bottom: -150 });
stubRect(running.blocks[1]!, { top: -140, bottom: -60 });
stubRect(running.blocks[2]!, { top: -20, bottom: 40 });
const anchor = readTopVisibleTurnAnchor(running.list);
expect(anchor).toEqual({ key: 't2', blockKey: 'g2', offset: -20 });
// 收口:过程折进 details,g2 的块序号从 1 变成 3;用户把过程展开后它有真实几何。
const finished = buildList([
['t2', 'u'],
['t2', 'process'],
['t2', 'g1'],
['t2', 'g2'],
['t2', 'f'],
['t2', 'usage'],
]);
stubRect(finished.blocks[0]!, { top: -200, bottom: -150 });
stubRect(finished.blocks[1]!, { top: -120, bottom: -90 });
stubRect(finished.blocks[2]!, { top: -80, bottom: -20 });
stubRect(finished.blocks[3]!, { top: 150, bottom: 250 });
stubRect(finished.blocks[4]!, { top: 260, bottom: 300 });
stubRect(finished.blocks[5]!, { top: 310, bottom: 340 });
finished.list.scrollTop = 0;
expect(restoreTurnAnchor(finished.list, anchor!)).toBe(true);
// 150 - 0 - (-20):按 g2 自己的位置算;按序号会错用 g1 的 -80,写出一副跳动的画面。
expect(finished.list.scrollTop).toBe(170);
});
it('锚点对应的块已经不在了:返回 false,不动 scrollTop', () => {
const { list } = buildList([['t1', 'b1']]);
list.scrollTop = 30;
expect(
restoreTurnAnchor(list, { key: 'missing', blockKey: 'b1', offset: -10 }),
).toBe(false);
expect(list.scrollTop).toBe(30);
});
@@ -1,9 +1,18 @@
/**
* 更早历史前插时的视口锚点:读取与还原。
*
* 锚点粒度是「回合 key(`data-turn-key`)+ 该回合内的块序号 + 相对列表顶边的像素偏移」:
* 合并更早历史只会把新回合插在更早的位置,已有回合的 key、块序号和块内结构都不变,所以这个
* 锚点跨前插稳定。还原只改 `scrollTop`,不做任何 DOM 变更。
* 锚点粒度是「回合 key(`data-turn-key`)+ 块身份(`data-block-key`)+ 相对列表顶边的像素偏移」。
* 块身份就是 `DirectChatBlock.key`(条目块是 `${回合 key}:${条目 itemId}`,本地说明块是
* `messageId`),只要求在同一回合内唯一;「执行过程」包装块与终态文案各有一个回合内唯一的
* 字面量块身份(见 `DirectProjectTurn.tsx`)。
*
* 为什么不存块序号:`DirectProjectTurn` 对运行中的回合把过程块平铺、对已结束的回合把它们折进
* 一个 `<details>`,收口时整个回合的块序号都会后移(`[用户, 工具组1, 工具组2]` →
* `[用户, 执行过程, 工具组1, 工具组2, 终态正文, 终态文案]`),序号锚点会解析到隔壁块。
* 合并更早历史只把新回合插在更早的位置,块身份跨前插稳定。还原只改 `scrollTop`。
*
* 块没有布局盒(被折进**收起**的 `<details>`、或已不可见)时不参与:读锚点时跳过它,还原时
* 直接判失败(返回 false),由调用方按当前位置重新起锚,而不是拿 0 当基准写 `scrollTop`。
*
* 为什么不用浏览器原生 scroll anchoring:原生补偿只能让"当前可见内容"不动,做不到「跟随时
* 展开要贴底」,两套补偿同时生效还会互相抵消;列表因此显式关闭原生锚定,补偿只走这一份。
@@ -12,13 +21,14 @@
export type ConversationTurnAnchor = {
/** 回合身份:`DirectChatTurn.key`。 */
key: string;
/** 同一个回合里的第几个块(`data-turn-key` 相同的节点按文档顺序编号)。 */
index: number;
/** 块身份:`data-block-key`,同一个回合内唯一。 */
blockKey: string;
/** 该块顶边相对列表顶边的偏移,可为负。 */
offset: number;
};
const TURN_BLOCK_SELECTOR = '[data-turn-key]';
/** 可锚定的块:同时带回合 key 与块身份。两者缺一都定位不回来,干脆不进候选集合。 */
const TURN_BLOCK_SELECTOR = '[data-turn-key][data-block-key]';
function turnKeyOf(element: Element): string | null {
return element instanceof HTMLElement
@@ -49,16 +59,18 @@ export function invalidateTurnBlocks(list: HTMLElement): void {
turnBlockCache.delete(list);
}
/** 按「回合 key + 块序号」定位具体块;找不到返回 null。 */
/** 按「回合 key + 块身份」定位具体块;找不到返回 null。 */
export function findTurnBlock(
list: HTMLElement,
key: string,
index: number,
blockKey: string,
): HTMLElement | null {
const blocks = collectTurnBlocks(list).filter(
(block) => turnKeyOf(block) === key,
return (
collectTurnBlocks(list).find(
(block) =>
turnKeyOf(block) === key && block.dataset.blockKey === blockKey,
) ?? null
);
return blocks[index] ?? null;
}
/**
@@ -71,21 +83,19 @@ export function readTopVisibleTurnAnchor(
list: HTMLElement,
): ConversationTurnAnchor | null {
const listTop = list.getBoundingClientRect().top;
const blocks = collectTurnBlocks(list);
for (let position = 0; position < blocks.length; position += 1) {
const block = blocks[position]!;
if (block.getBoundingClientRect().bottom - listTop <= 0) continue;
for (const block of collectTurnBlocks(list)) {
const rect = block.getBoundingClientRect();
// 折进收起 `<details>` 的块在真实浏览器里没有布局盒(矩形全 0):它既不是用户正在读的
// 位置,也当不了还原基准。
if (rect.height <= 0) continue;
if (rect.bottom - listTop <= 0) continue;
const key = turnKeyOf(block);
if (!key) continue;
const blockKey = block.dataset.blockKey;
if (!key || !blockKey) continue;
let index = 0;
for (let earlier = 0; earlier < position; earlier += 1) {
if (turnKeyOf(blocks[earlier]!) === key) index += 1;
}
return { key, index, offset: block.getBoundingClientRect().top - listTop };
return { key, blockKey, offset: rect.top - listTop };
}
return null;
@@ -94,17 +104,20 @@ export function readTopVisibleTurnAnchor(
/**
* 按锚点还原 `scrollTop`,让同一个块回到原来的偏移。
*
* 返回是否还原成功:锚点对应的块已经不在列表里时返回 false,调用方可以据此放弃这次补偿,
* 而不是拿着过期偏移去改 `scrollTop`。
* 返回是否还原成功:锚点对应的块已经不在列表里、或它已经被折进收起的 `<details>`(矩形全 0,
* 算不出可用偏移)时返回 false,调用方据此拿当前位置重新起锚,而不是拿着过期基准改 `scrollTop`。
*/
export function restoreTurnAnchor(
list: HTMLElement,
anchor: ConversationTurnAnchor,
): boolean {
const block = findTurnBlock(list, anchor.key, anchor.index);
const block = findTurnBlock(list, anchor.key, anchor.blockKey);
if (!block) return false;
const listTop = list.getBoundingClientRect().top;
list.scrollTop += block.getBoundingClientRect().top - listTop - anchor.offset;
const rect = block.getBoundingClientRect();
if (rect.height <= 0) return false;
list.scrollTop += rect.top - listTop - anchor.offset;
return true;
}
@@ -178,7 +178,8 @@ export function useConversationScroll({
}
return;
}
// 锚点对应的块已经不在了(折叠态换过结构):拿当前位置重新起锚,别用过期偏移。
// 锚点还原失败(块已经不在列表里,或收口后被折进收起的 `<details>`、没有布局盒算不出偏移):
// 拿当前位置重新起锚,别用过期基准写 `scrollTop`;只是这次不自动对齐,不会回跳。
preserveAnchorRef.current = readTopVisibleTurnAnchor(list);
}, []);