修复:会话滚动锚点改用稳定块身份定位
锚点从「回合 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:
+147
-23
@@ -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);
|
||||
});
|
||||
|
||||
+39
-26
@@ -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;
|
||||
}
|
||||
|
||||
+2
-1
@@ -178,7 +178,8 @@ export function useConversationScroll({
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 锚点对应的块已经不在了(折叠态换过结构):拿当前位置重新起锚,别用过期偏移。
|
||||
// 锚点还原失败(块已经不在列表里,或收口后被折进收起的 `<details>`、没有布局盒算不出偏移):
|
||||
// 拿当前位置重新起锚,别用过期基准写 `scrollTop`;只是这次不自动对齐,不会回跳。
|
||||
preserveAnchorRef.current = readTopVisibleTurnAnchor(list);
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user