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