diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.test.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.test.ts index 58a236cc5..7f53aa62c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.test.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.test.ts @@ -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 }); + // 被折进收起 `
` 的块在真实浏览器里矩形全 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); }); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.ts index 4511247e6..765104d5a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.ts @@ -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` 对运行中的回合把过程块平铺、对已结束的回合把它们折进 + * 一个 `
`,收口时整个回合的块序号都会后移(`[用户, 工具组1, 工具组2]` → + * `[用户, 执行过程, 工具组1, 工具组2, 终态正文, 终态文案]`),序号锚点会解析到隔壁块。 + * 合并更早历史只把新回合插在更早的位置,块身份跨前插稳定。还原只改 `scrollTop`。 + * + * 块没有布局盒(被折进**收起**的 `
`、或已不可见)时不参与:读锚点时跳过它,还原时 + * 直接判失败(返回 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(); + // 折进收起 `
` 的块在真实浏览器里没有布局盒(矩形全 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`。 + * 返回是否还原成功:锚点对应的块已经不在列表里、或它已经被折进收起的 `
`(矩形全 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; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts index f71dc4216..5031106b0 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts @@ -178,7 +178,8 @@ export function useConversationScroll({ } return; } - // 锚点对应的块已经不在了(折叠态换过结构):拿当前位置重新起锚,别用过期偏移。 + // 锚点还原失败(块已经不在列表里,或收口后被折进收起的 `
`、没有布局盒算不出偏移): + // 拿当前位置重新起锚,别用过期基准写 `scrollTop`;只是这次不自动对齐,不会回跳。 preserveAnchorRef.current = readTopVisibleTurnAnchor(list); }, []);