diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 3c54e94..2b1b7f0 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -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 终态 → 末尾补持久卡(所有重渲路径统一走这)