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 new file mode 100644 index 000000000..077b41d31 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.ts @@ -0,0 +1,92 @@ +/** + * 更早历史前插时的视口锚点:读取与还原。 + * + * 锚点粒度是「回合 key(`data-turn-key`)+ 该回合内的块序号 + 相对列表顶边的像素偏移」: + * 合并更早历史只会把新回合插在更早的位置,已有回合的 key、块序号和块内结构都不变,所以这个 + * 锚点跨前插稳定。还原只改 `scrollTop`,不做任何 DOM 变更。 + * + * 为什么不用浏览器原生 scroll anchoring:原生补偿只能让"当前可见内容"不动,做不到「跟随时 + * 展开要贴底」,两套补偿同时生效还会互相抵消;列表因此显式关闭原生锚定,补偿只走这一份。 + */ + +export type ConversationTurnAnchor = { + /** 回合身份:`DirectChatTurn.key`。 */ + key: string; + /** 同一个回合里的第几个块(`data-turn-key` 相同的节点按文档顺序编号)。 */ + index: number; + /** 该块顶边相对列表顶边的偏移,可为负。 */ + offset: number; +}; + +const TURN_BLOCK_SELECTOR = '[data-turn-key]'; + +function turnKeyOf(element: Element): string | null { + return element instanceof HTMLElement + ? (element.dataset.turnKey ?? null) + : null; +} + +/** 列表里所有带回合身份的块,按文档顺序。 */ +export function collectTurnBlocks(list: HTMLElement): HTMLElement[] { + return Array.from(list.querySelectorAll(TURN_BLOCK_SELECTOR)); +} + +/** 按「回合 key + 块序号」定位具体块;找不到返回 null。 */ +export function findTurnBlock( + list: HTMLElement, + key: string, + index: number, +): HTMLElement | null { + const blocks = collectTurnBlocks(list).filter( + (block) => turnKeyOf(block) === key, + ); + return blocks[index] ?? null; +} + +/** + * 顶部第一条真正露出视口的块。 + * + * 用 `bottom > 列表顶边` 而不是 `top >= 列表顶边`:滚到中间时最上面那条通常只露出一半, + * 它才是"用户正在读的位置",拿下一整条当锚点会在前插时把半条消息顶出去。 + */ +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; + + const key = turnKeyOf(block); + if (!key) 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 null; +} + +/** + * 按锚点还原 `scrollTop`,让同一个块回到原来的偏移。 + * + * 返回是否还原成功:锚点对应的块已经不在列表里时返回 false,调用方可以据此放弃这次补偿, + * 而不是拿着过期偏移去改 `scrollTop`。 + */ +export function restoreTurnAnchor( + list: HTMLElement, + anchor: ConversationTurnAnchor, +): boolean { + const block = findTurnBlock(list, anchor.key, anchor.index); + if (!block) return false; + + const listTop = list.getBoundingClientRect().top; + list.scrollTop += block.getBoundingClientRect().top - listTop - anchor.offset; + return true; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.ts new file mode 100644 index 000000000..845aec2c7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollPolicy.ts @@ -0,0 +1,114 @@ +/** + * DirectProject 会话滚动与更早历史自动加载的判据(常量 + 纯函数)。 + * + * 这一层只有数字与文案,不碰 DOM:阈值、触发条件、胶囊文案都在这里定死, + * `useConversationScroll` 只负责把真实几何喂进来。契约与取舍见 + * `docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md`。 + */ + +/** + * 距列表顶边多少像素以内算「触顶」,触发更早历史加载。 + * + * 必须小于 {@link DIRECT_SCROLL_BOTTOM_THRESHOLD}:两个阈值如果相等,刚往下滚一点就同时 + * 被判成「触顶」和「在底部」,历史加载与跟随最新会互相打架。 + */ +export const DIRECT_HISTORY_TOP_THRESHOLD = 24; + +/** 距底边多少像素以内算「在底部」:决定是否跟随最新,以及回到底部胶囊的显隐。 */ +export const DIRECT_SCROLL_BOTTOM_THRESHOLD = 48; + +/** 加载行延迟多少毫秒才显示:本地读取常常瞬间返回,立即显示会闪一下。 */ +export const HISTORY_LOADING_INDICATOR_DELAY_MS = 150; + +export const HISTORY_LOADING_TEXT = '正在加载更早的对话'; +export const HISTORY_ERROR_TEXT = '加载更早对话失败'; +export const HISTORY_RETRY_TEXT = '重试'; +export const SCROLL_TO_BOTTOM_TEXT = '回到底部'; +export const SCROLL_TO_BOTTOM_UNREAD_TEXT = '有新回复 · 回到底部'; + +/** 滚动容器当前几何;三个值都取真实布局值,不做任何补偿。 */ +export type ConversationListMetrics = { + scrollTop: number; + scrollHeight: number; + clientHeight: number; +}; + +export function readConversationListMetrics(element: { + scrollTop: number; + scrollHeight: number; + clientHeight: number; +}): ConversationListMetrics { + return { + scrollTop: element.scrollTop, + scrollHeight: element.scrollHeight, + clientHeight: element.clientHeight, + }; +} + +/** + * 距底部还有多少像素。 + * + * 内容比视口短时按 0 处理:此时「距底部」是负数,直接比较会让 `isNearBottom` 恒真以外的 + * 阈值判断出现反直觉结果。 + */ +export function distanceFromBottom(metrics: ConversationListMetrics): number { + return Math.max( + 0, + metrics.scrollHeight - metrics.scrollTop - metrics.clientHeight, + ); +} + +export function isNearBottom(metrics: ConversationListMetrics): boolean { + return distanceFromBottom(metrics) <= DIRECT_SCROLL_BOTTOM_THRESHOLD; +} + +export function isNearHistoryTop(metrics: ConversationListMetrics): boolean { + return metrics.scrollTop <= DIRECT_HISTORY_TOP_THRESHOLD; +} + +/** + * 内容还没填满视口。 + * + * 「显示更早的对话」按钮删除后,短历史只能靠这条判据继续补:只要还有 `hasMore`,就一直加载到 + * 填满视口或加载器说没有更早历史为止,否则用户看到的就是一块拉不动的静止画面。 + */ +export function needsViewportFill(metrics: ConversationListMetrics): boolean { + return metrics.scrollHeight <= metrics.clientHeight; +} + +/** 自动加载更早历史的前置门:三条都成立才允许发起。 */ +export type ConversationHistoryLoadGate = { + historyHasMore: boolean; + historyLoading: boolean; + historyError: string | null; +}; + +function canStartHistoryLoad(gate: ConversationHistoryLoadGate): boolean { + return gate.historyHasMore && !gate.historyLoading && !gate.historyError; +} + +/** + * 滚动触顶时的自动加载判据。 + * + * 失败后挂起:`historyError` 非空期间不再自动触发,只有手动重试(清掉错误)或切换项目才会 + * 重新开始——弱网下反复重试既看不到反馈,也会持续打接口。 + */ +export function shouldLoadEarlierOnScroll( + metrics: ConversationListMetrics, + gate: ConversationHistoryLoadGate, +): boolean { + return canStartHistoryLoad(gate) && isNearHistoryTop(metrics); +} + +/** 首帧之后填充视口的自动加载判据,与滚动触发共用同一道门。 */ +export function shouldFillViewport( + metrics: ConversationListMetrics, + gate: ConversationHistoryLoadGate, +): boolean { + return canStartHistoryLoad(gate) && needsViewportFill(metrics); +} + +/** 回到底部胶囊的文案:不跟随时又来了新内容才提示「有新回复」。 */ +export function scrollToBottomLabel(hasNewReply: boolean): string { + return hasNewReply ? SCROLL_TO_BOTTOM_UNREAD_TEXT : SCROLL_TO_BOTTOM_TEXT; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.ts new file mode 100644 index 000000000..1956fbc94 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationToggleReveal.ts @@ -0,0 +1,89 @@ +/** + * 折叠块展开 / 收起后的视口处理。 + * + * 展开一个 `
` 或 `[aria-expanded]` 块不会改变 `scrollTop`,但会把正文长在视口下方: + * 用户必须再滚一次才能看到刚展开的内容。这里的做法是: + * 1. 头部仍在视口里 → 把它拉回展开前的屏幕位置(折叠头冻结); + * 2. 展开正文比视口还高、头部已经锚不住 → 对齐正文顶边,让用户从正文开头读起。 + * + * 判断所需的"展开前位置"由调用方在点击捕获阶段记录,见 `useConversationScroll`。 + */ + +export type ConversationToggleFold = { + /** 触发折叠的头部元素:`
` 的 `summary`,或带 `aria-expanded` 的按钮。 */ + head: HTMLElement; + /** 头部在展开前相对视口顶边的位置(`getBoundingClientRect().top`)。 */ + headTop: number; +}; + +/** + * 从点击目标向上找折叠头。 + * + * 两种折叠头都要认:`DirectProjectTurn` 的「执行过程」是 `
`,`ToolCallGroup` 与 + * 「思考过程」是带 `aria-expanded` 的按钮。 + */ +export function resolveToggleHead(target: Element | null): HTMLElement | null { + if (!target) return null; + const summary = target.closest('summary'); + if (summary instanceof HTMLElement) return summary; + const expandable = target.closest('[aria-expanded]'); + if (expandable instanceof HTMLElement) return expandable; + return null; +} + +/** 折叠头对应的正文元素;解析不出来返回 null(此时只做头部冻结)。 */ +export function resolveToggleBody(head: HTMLElement): HTMLElement | null { + if (head.tagName === 'SUMMARY') { + const details = head.closest('details'); + if (!details) return null; + const body = Array.from(details.children).find( + (child) => child.tagName !== 'SUMMARY', + ); + return body instanceof HTMLElement ? body : null; + } + + const controls = head.getAttribute('aria-controls'); + if (!controls) return null; + return head.ownerDocument.getElementById(controls); +} + +/** + * 布局变化后把折叠头拉回展开前的位置;返回是否做了补偿。 + * + * 头部已经不在视口里时返回 false 并保持原状:继续按旧位置冻结会让画面跳到用户没在看的地方, + * 这时应该退回到「还原顶部可见回合锚点」。 + */ +export function freezeToggleHead( + list: HTMLElement, + fold: ConversationToggleFold, +): boolean { + if (!fold.head.isConnected) return false; + + const listRect = list.getBoundingClientRect(); + const headRect = fold.head.getBoundingClientRect(); + const headVisible = + headRect.bottom > listRect.top && headRect.top < listRect.bottom; + if (!headVisible) return false; + + list.scrollTop += headRect.top - fold.headTop; + revealExpandedBodyTop(list, fold.head); + return true; +} + +/** + * 展开正文高于视口且底部超出视口时,把正文顶边对齐到视口顶边。 + * + * 正文能在视口里放得下就不介入:那种情况下头部冻结已经足够,再动一次会把刚冻住的头部推走。 + */ +function revealExpandedBodyTop(list: HTMLElement, head: HTMLElement): void { + const body = resolveToggleBody(head); + if (!body) return; + + const listRect = list.getBoundingClientRect(); + const bodyRect = body.getBoundingClientRect(); + const viewportHeight = listRect.bottom - listRect.top; + if (bodyRect.height <= viewportHeight) return; + if (bodyRect.bottom <= listRect.bottom) return; + + list.scrollTop += bodyRect.top - listRect.top; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useDelayedFlag.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useDelayedFlag.ts new file mode 100644 index 000000000..41a5c6852 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useDelayedFlag.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; + +/** + * 延迟显示一个开关位:为真后等 `delayMs` 才转成可见,转假立即隐藏。 + * + * 用来避免「本地读取瞬间返回」时闪一下加载行:反馈要等得起,但不能一闪而过。 + */ +export function useDelayedFlag(value: boolean, delayMs: number): boolean { + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (!value) { + setVisible(false); + return undefined; + } + + const timer = setTimeout(() => setVisible(true), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return visible; +}