fix(web): 长对话点目录圆点首次跳不到位——重建窗口瞬时定位+跳转期抑制 sentinel 补载

根因链:loadMessagesAround 后 renderMessages 尾部无条件滚底,把视口钉到
新窗口末尾;底部 sentinel 落在视口内立刻触发 loadNewerMessages,其整窗
重渲染(innerHTML 清空)删掉平滑滚动的目标元素、再把 scrollTop 恢复到
fetch 前位置——首次点击停在窗口底部附近,第二次点击才能到位。

- renderMessages 加 stickBottom 参数(默认 true 保持首屏/发送行为);
  loadEarlier/loadNewer/loadMessagesAround 三个调窗口路径传 false
- jumpToMessage 重建窗口后用瞬时定位(auto),不给补载/图片异步撑高留
  动画窗口期;已在 DOM 的近距离跳转保留平滑
- _msgScrollObserver 在 _outlineJumpLock 期间不补载;解锁时
  _repokeMsgSentinels 对 sentinel unobserve+observe 补投一次交叉状态,
  避免 sentinel 停留在视口内时解锁后无人触发加载

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-21 16:42:41 +08:00
parent 2e3a4db4e0
commit fc53d21620
1 changed files with 31 additions and 6 deletions

View File

@ -691,7 +691,7 @@ async function loadEarlierMessages() {
if (earlier.length) state.loadedMessages = earlier.concat(state.loadedMessages);
state.msgHasMore = !!data.has_more;
state.msgLoadingEarlier = false;
renderMessages(state.loadedMessages);
renderMessages(state.loadedMessages, { stickBottom: false });
// 锚回:新增内容都在上方,保持原先可见的首条仍在原位
wrap.scrollTop = prevTop + (wrap.scrollHeight - prevH);
} catch (e) {
@ -720,7 +720,7 @@ async function loadNewerMessages() {
if (newer.length) state.loadedMessages = state.loadedMessages.concat(newer);
state.msgHasMoreNewer = !!data.has_more_after;
state.msgLoadingNewer = false;
renderMessages(state.loadedMessages);
renderMessages(state.loadedMessages, { stickBottom: false });
wrap.scrollTop = prevTop; // 新增在下方,保持原视口不跳
} catch (e) {
state.msgLoadingNewer = false;
@ -755,19 +755,23 @@ async function loadMessagesAround(idx) {
state.msgHasMoreNewer = !!data.has_more_after;
state.msgLoadingEarlier = false;
state.msgLoadingNewer = false;
renderMessages(state.loadedMessages);
// 不滚底:jumpToMessage 随后瞬时定位到目标卡;滚底会把视口钉到窗口末尾,
// 底部 sentinel 立刻进视口触发补载重渲染,打断跳转(首次点圆点跳不到位的根因)
renderMessages(state.loadedMessages, { stickBottom: false });
return true;
}
async function jumpToMessage(idx) {
const wrap = $("chat-stream");
let card = wrap.querySelector(`.msg[data-idx="${idx}"]`);
let rebuilt = false; // 本次跳转是否重建了消息窗口
if (!card) {
let ok = false;
try { ok = await loadMessagesAround(idx); }
catch (e) { if (e.status === 401) { logout(); return; } }
if (!ok) return;
card = wrap.querySelector(`.msg[data-idx="${idx}"]`);
rebuilt = true;
}
if (!card) return;
// 顶部对齐(非居中):第一轮上方无内容无法居中、会被钉到顶端,而 updateActiveOutlineDot
@ -775,7 +779,10 @@ async function jumpToMessage(idx) {
// .msg 的 scroll-margin-top 给卡片留一点上方呼吸空间。
setActiveOutlineIdx(idx);
lockOutlineDuringJump(); // 锁住活跃圆点:平滑滚动途中的 scroll 事件不得把活跃态抢到途经轮次
card.scrollIntoView({ behavior: "smooth", block: "start" });
// 重建窗口后瞬时定位:视口位置还停在旧窗口的残留值,平滑动画要横穿整窗,
// 途中 sentinel 补载 / 图片异步撑高都会让落点偏掉;瞬时跳没有窗口期。
// 已在 DOM 的近距离跳转保留平滑。
card.scrollIntoView({ behavior: rebuilt ? "auto" : "smooth", block: "start" });
card.classList.add("msg-jump-flash");
setTimeout(() => card.classList.remove("msg-jump-flash"), 1200);
}
@ -783,6 +790,10 @@ async function jumpToMessage(idx) {
// 顶/底 sentinel 进视口即自动补更早 / 更新 —— 复用 task list 的同款范式。
// root 是 chat-stream 滚动容器;每次 renderMessages 重建 DOM 后重新 observe 新 sentinel。
const _msgScrollObserver = new IntersectionObserver((entries) => {
// 目录跳转动画期间不自动补载:补载的整窗重渲染会删掉平滑滚动的目标元素、
// 把视口拽回 fetch 前的位置(表现:点圆点跳不到位)。解锁时 _repokeMsgSentinels
// 会补投一次当前交叉状态,不会漏加载。
if (_outlineJumpLock) return;
for (const en of entries) {
if (!en.isIntersecting) continue;
if (en.target.classList.contains("msg-top-sentinel")) {
@ -793,6 +804,16 @@ const _msgScrollObserver = new IntersectionObserver((entries) => {
}
}, { root: $("chat-stream"), rootMargin: "150px 0px" });
// IntersectionObserver 只在交叉状态变化时通知 —— 锁定期吞掉的通知不会重发,
// 若 sentinel 一直停在视口内,解锁后就没人触发补载了。unobserve+observe
// 强制 observer 重新投递一次当前交叉状态,补上这一拍。
function _repokeMsgSentinels() {
$("chat-stream").querySelectorAll(".msg-top-sentinel, .msg-bot-sentinel").forEach((el) => {
_msgScrollObserver.unobserve(el);
_msgScrollObserver.observe(el);
});
}
// ───── 消息目录(右侧悬浮圆点轨道)─────
// 切 task / run 收尾后拉全部 user 轮次;点圆点 jumpToMessage 定位;滚动时高亮当前轮。
async function refreshOutline() {
@ -847,6 +868,7 @@ function lockOutlineDuringJump() {
_outlineJumpTimer = setTimeout(() => {
_outlineJumpLock = false;
updateActiveOutlineDot();
_repokeMsgSentinels(); // 锁定期吞掉的 sentinel 通知补投一次(见 observer 注释)
}, 700);
}
@ -1149,7 +1171,10 @@ async function copyTextToClipboard(text, btn) {
}
}
function renderMessages(msgs) {
// stickBottom:渲染完是否滚到底。首屏/发送等"看最新"路径保持默认 true;
// 调窗口的路径(向上/向下补载、目录跳转)传 false —— 它们各自负责定位视口,
// 中间插一次滚底会触发多余 scroll 事件,跳转路径还会把视口钉错位置(0.58.55 修)。
function renderMessages(msgs, { stickBottom = true } = {}) {
const wrap = $("chat-stream");
_msgScrollObserver.disconnect(); // 旧 sentinel 随 innerHTML 清掉,先断开避免悬挂 observe
wrap.innerHTML = "";
@ -1302,7 +1327,7 @@ function renderMessages(msgs) {
wrap.appendChild(sb);
_msgScrollObserver.observe(sb);
}
wrap.scrollTop = wrap.scrollHeight;
if (stickBottom) wrap.scrollTop = wrap.scrollHeight;
setTaskProgress(state.taskId, currentProgressSteps);
upgradeMediaArtifacts(wrap);
renderPersistedRunTerminal(); // 上次 run error/cancelled 终态 → 末尾补持久卡(所有重渲路径统一走这)