Files
CyberStrikeAI/web/static/js/chat-scroll.js
T
RuoJi6andGitHub eb6bab574f 优化项目对话、刷新续流、实时滚动与工具状态恢复 (#245)
* feat(chat): add project-based conversation sidebar

* feat(chat): refine Codex-style conversation UI

* feat(chat): add Codex-style conversation workflow

* feat(ui): 优化对话框与项目侧边栏交互

* fix(chat): 修复暗色输入框圆角填色

* fix(chat): 恢复输入区分层错位布局

* fix(hitl): isolate reviewer state per conversation

* feat(hitl): 增加双入口审批与倒计时进度

* fix(hitl): 汇总项目审批并隔离对话状态

* fix(ui): 修复审批状态与无项目新任务

* fix(ui): 优化审批状态与对话切换性能

* fix(ui): 修复中断任务审批仍计时

* fix(ui): 修复多对话并发切换卡顿

* fix(hitl): 主动同步审批并关闭中断状态

* fix(ui): 固定项目审批汇总为绿色

* feat(ui): 同步系统模型与推理强度

* feat(ui): 完善项目侧栏预览与新建入口

* fix(ui): 防止无项目文件夹误展开

* fix(ui): 防止长历史对话滚动误触审批

* fix(ui): 修复对话操作并补充项目置顶

* fix(ui): 移除对话分组并调整项目置顶排序

* feat(ui): 优化迭代导航与审批交互

* fix(hitl): 将 write_file 加入内置免审批工具

* fix(chat): 支持回车发送与 Shift 回车换行

* fix(chat): 优化对话刷新与 Codex 风格交互

* fix(ui): 显示对话具体更新时间

* fix(ui): 优化对话刷新与项目加载

* fix(ui): 修复 Agent 审查文字裁切

* fix(chat): 修复刷新续流与多标签页同步

* fix(chat): 修复滚动跟随与中断任务终态

* fix(ui): 修复流式滚动跳动与工具状态恢复

* fix(ui): 修复刷新后流式输出停止粘底
2026-08-13 21:28:37 +08:00

848 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 主对话区智能粘底滚动:流式输出时自动跟随,用户上滑阅读时不抢焦点。
* 主 POST 流(sendMessage)与刷新后 task-events 补流共用同一策略。
*/
(function () {
'use strict';
/** 距底部在此范围内才继续自动跟随(宜小,避免“差一点也被拽回去”) */
const CHAT_SCROLL_FOLLOW_THRESHOLD_PX = 48;
/** 只有真正到达底部才恢复跟随;2px 用于兼容高分屏的亚像素滚动。 */
const CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX = 2;
/** 到达此范围视为位于最后一轮 */
const CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX = 120;
/** 用户上滑后的短暂锁,防止 SSE 与 scroll 事件竞态抢滚动 */
const DETACH_LOCK_MS = 900;
/** 刷新恢复会跨越历史消息、过程详情、字体与流订阅等多轮异步布局。 */
const CONVERSATION_RESTORE_SETTLE_MIN_MS = 3000;
const CONVERSATION_RESTORE_SETTLE_MAX_MS = 6000;
const CONVERSATION_RESTORE_STABLE_FRAMES = 12;
/** @type {'following' | 'detached'} */
let scrollMode = 'following';
let scrollFollowRaf = 0;
let scrollSettleGeneration = 0;
let conversationRestoreGeneration = 0;
/** 用户脱离跟随后,下方是否有未读的新输出(不按 SSE 次数计) */
let hasPendingNewBelow = false;
let listenersBound = false;
let lastScrollTop = 0;
let lastScrollHeight = 0;
let programmaticScroll = false;
let detachLockUntil = 0;
/** 最近一次由用户发起的滚动意图;布局变化或脚本滚动不得据此恢复粘底。 */
let userScrollIntentUntil = 0;
let turnRailRefreshRaf = 0;
let turnRailSignature = '';
let activeTurnIndex = -1;
let turnRailObserver = null;
let chatMessagesResizeObserver = null;
let turnPreviewHideTimer = 0;
function getChatMessagesEl() {
return document.getElementById('chat-messages');
}
function getTurnRailEl() {
return document.getElementById('chat-turn-rail');
}
function getTurnRailMarkersEl() {
return document.getElementById('chat-turn-rail-markers');
}
function getReturnLatestButton() {
return document.getElementById('chat-return-latest');
}
function normalizePreviewText(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function trimPreviewText(value, maxLength) {
const text = normalizePreviewText(value);
if (text.length <= maxLength) return text;
return text.slice(0, Math.max(1, maxLength - 1)).trimEnd() + '…';
}
function messagePreviewText(messageEl) {
if (!messageEl) return '';
const original = messageEl.dataset ? messageEl.dataset.originalContent : '';
if (original) return normalizePreviewText(original);
const bubble = messageEl.querySelector('.assistant-final-result, .message-bubble');
if (!bubble) return '';
const clone = bubble.cloneNode(true);
clone.querySelectorAll('button, .message-copy-btn, .progress-actions, .progress-footer, .process-details-content').forEach(function (el) {
el.remove();
});
return normalizePreviewText(clone.textContent);
}
/** 每条用户消息开始一轮,直到下一条用户消息前的助手消息都归入该轮。 */
function collectConversationTurns() {
const messagesEl = getChatMessagesEl();
if (!messagesEl) return [];
const turns = [];
let currentTurn = null;
Array.from(messagesEl.children).forEach(function (messageEl) {
if (!messageEl.classList || !messageEl.classList.contains('message')) return;
if (messageEl.classList.contains('user')) {
currentTurn = { user: messageEl, assistants: [] };
turns.push(currentTurn);
return;
}
if (currentTurn && messageEl.classList.contains('assistant')) {
currentTurn.assistants.push(messageEl);
}
});
return turns;
}
function localizedTurnLabel(index, question) {
const number = index + 1;
const prefix = typeof window.t === 'function'
? window.t('chat.turnNumber', { number: number })
: '第 ' + number + ' 轮';
const safePrefix = prefix && prefix !== 'chat.turnNumber' ? prefix : ('第 ' + number + ' 轮');
return question ? safePrefix + '' + question : safePrefix;
}
function turnPreviewData(turn, index) {
const question = trimPreviewText(messagePreviewText(turn && turn.user), 100)
|| localizedTurnLabel(index, '');
const assistants = turn && turn.assistants ? turn.assistants : [];
let assistant = null;
for (let i = assistants.length - 1; i >= 0; i--) {
if (!assistants[i].classList.contains('progress-message')) {
assistant = assistants[i];
break;
}
}
if (!assistant && assistants.length) assistant = assistants[assistants.length - 1];
let summary = trimPreviewText(messagePreviewText(assistant), 220);
if (!summary) {
summary = typeof window.t === 'function' ? window.t('chat.turnPending') : '正在处理…';
if (!summary || summary === 'chat.turnPending') summary = '正在处理…';
}
return { question: question, summary: summary };
}
function hideTurnPreview() {
if (turnPreviewHideTimer) {
window.clearTimeout(turnPreviewHideTimer);
turnPreviewHideTimer = 0;
}
const preview = document.getElementById('chat-turn-rail-preview');
if (preview) preview.hidden = true;
}
function scheduleHideTurnPreview() {
if (turnPreviewHideTimer) window.clearTimeout(turnPreviewHideTimer);
turnPreviewHideTimer = window.setTimeout(hideTurnPreview, 160);
}
function showTurnPreview(marker, index) {
if (turnPreviewHideTimer) {
window.clearTimeout(turnPreviewHideTimer);
turnPreviewHideTimer = 0;
}
const preview = document.getElementById('chat-turn-rail-preview');
const title = document.getElementById('chat-turn-rail-preview-title');
const summary = document.getElementById('chat-turn-rail-preview-summary');
const turn = collectConversationTurns()[index];
if (!preview || !title || !summary || !marker || !turn) return;
const data = turnPreviewData(turn, index);
title.textContent = data.question;
summary.textContent = data.summary;
preview.hidden = false;
const markerRect = marker.getBoundingClientRect();
const previewRect = preview.getBoundingClientRect();
const left = Math.min(markerRect.right + 18, window.innerWidth - previewRect.width - 12);
const desiredTop = markerRect.top + markerRect.height / 2 - previewRect.height / 2;
const top = Math.max(12, Math.min(desiredTop, window.innerHeight - previewRect.height - 12));
preview.style.left = Math.max(12, left) + 'px';
preview.style.top = top + 'px';
}
function setActiveTurnMarker(index) {
const markersEl = getTurnRailMarkersEl();
if (!markersEl) return;
const markers = Array.from(markersEl.querySelectorAll('.chat-turn-rail-marker'));
if (!markers.length) return;
const nextIndex = Math.max(0, Math.min(index, markers.length - 1));
markers.forEach(function (marker, markerIndex) {
const active = markerIndex === nextIndex;
marker.classList.toggle('is-active', active);
if (active) marker.setAttribute('aria-current', 'step');
else marker.removeAttribute('aria-current');
});
markers[markers.length - 1].classList.toggle('has-pending-new', hasPendingNewBelow);
if (activeTurnIndex !== nextIndex) {
activeTurnIndex = nextIndex;
const activeMarker = markers[nextIndex];
const markerTop = activeMarker.offsetTop;
const markerBottom = markerTop + activeMarker.offsetHeight;
if (markerTop < markersEl.scrollTop) {
markersEl.scrollTop = Math.max(0, markerTop - 8);
} else if (markerBottom > markersEl.scrollTop + markersEl.clientHeight) {
markersEl.scrollTop = markerBottom - markersEl.clientHeight + 8;
}
}
}
function updateTurnRailActive() {
const messagesEl = getChatMessagesEl();
const turns = collectConversationTurns();
if (!messagesEl || !turns.length) return;
if (isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX)) {
setActiveTurnMarker(turns.length - 1);
return;
}
const readingLine = messagesEl.scrollTop + messagesEl.clientHeight * 0.34;
let index = 0;
for (let i = 0; i < turns.length; i++) {
if (turns[i].user.offsetTop <= readingLine) index = i;
else break;
}
setActiveTurnMarker(index);
}
function jumpToConversationTurn(index) {
const messagesEl = getChatMessagesEl();
const turn = collectConversationTurns()[index];
if (!messagesEl || !turn || !turn.user) return;
setScrollDetached();
programmaticScroll = true;
messagesEl.scrollTo({
top: Math.max(0, turn.user.offsetTop - 20),
behavior: 'smooth'
});
setActiveTurnMarker(index);
hideTurnPreview();
window.setTimeout(function () {
programmaticScroll = false;
lastScrollTop = messagesEl.scrollTop;
updateTurnRailActive();
}, 420);
}
function focusTurnMarker(index) {
const markersEl = getTurnRailMarkersEl();
const marker = markersEl && markersEl.querySelector('.chat-turn-rail-marker[data-turn-index="' + index + '"]');
if (marker) marker.focus();
}
function rebuildTurnRail(force) {
const rail = getTurnRailEl();
const markersEl = getTurnRailMarkersEl();
if (!rail || !markersEl) return;
const turns = collectConversationTurns();
rail.hidden = turns.length === 0;
if (!turns.length) {
markersEl.replaceChildren();
turnRailSignature = '';
activeTurnIndex = -1;
hideTurnPreview();
return;
}
const signature = turns.map(function (turn, index) {
return (turn.user.id || ('turn-' + index)) + ':' + messagePreviewText(turn.user);
}).join('|');
if (!force && signature === turnRailSignature) {
updateTurnRailActive();
return;
}
const fragment = document.createDocumentFragment();
turns.forEach(function (turn, index) {
const marker = document.createElement('button');
const question = trimPreviewText(messagePreviewText(turn.user), 88);
marker.type = 'button';
marker.className = 'chat-turn-rail-marker';
marker.dataset.turnIndex = String(index);
marker.setAttribute('aria-label', localizedTurnLabel(index, question));
marker.addEventListener('click', function () {
jumpToConversationTurn(index);
});
marker.addEventListener('mouseenter', function () {
showTurnPreview(marker, index);
});
marker.addEventListener('mouseleave', scheduleHideTurnPreview);
marker.addEventListener('keydown', function (event) {
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
event.preventDefault();
focusTurnMarker(Math.min(turns.length - 1, index + 1));
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
event.preventDefault();
focusTurnMarker(Math.max(0, index - 1));
} else if (event.key === 'Home') {
event.preventDefault();
focusTurnMarker(0);
} else if (event.key === 'End') {
event.preventDefault();
focusTurnMarker(turns.length - 1);
}
});
fragment.appendChild(marker);
});
markersEl.replaceChildren(fragment);
turnRailSignature = signature;
activeTurnIndex = -1;
updateTurnRailActive();
}
function scheduleTurnRailRefresh(force) {
cancelAnimationFrame(turnRailRefreshRaf);
turnRailRefreshRaf = requestAnimationFrame(function () {
rebuildTurnRail(force === true);
});
}
function streamBelongsToVisibleConversation(stream) {
if (!stream || !stream.active) return false;
const visibleConversationId = typeof window.currentConversationId === 'string'
? window.currentConversationId.trim()
: '';
const streamConversationId = typeof stream.conversationId === 'string'
? stream.conversationId.trim()
: '';
// 新建对话在后端返回 conversationId 前,两边都为空,仍属于当前界面。
if (!streamConversationId) return !visibleConversationId;
return streamConversationId === visibleConversationId;
}
/** 只有当前可见对话的主 POST 流 / task-events 补流才视为「正在输出」 */
function isStreamActive() {
try {
const live = window.__csAgentLiveStream;
if (streamBelongsToVisibleConversation(live)) return true;
const replay = window.__csTaskEventStream;
return streamBelongsToVisibleConversation(replay);
} catch (e) {
return false;
}
}
function distanceFromBottom(el) {
if (!el) return 0;
const { scrollTop, scrollHeight, clientHeight } = el;
return scrollHeight - clientHeight - scrollTop;
}
function isNearBottom(thresholdPx) {
const el = getChatMessagesEl();
if (!el) return true;
return distanceFromBottom(el) <= thresholdPx;
}
function isChatMessagesPinnedToBottom() {
return isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX);
}
/** 已在底部时恢复 following(解决:手动滚到底但 scrollMode 仍为 detached */
function resumeFollowingIfAtBottom(thresholdPx, userInitiated) {
if (!userInitiated && Date.now() < detachLockUntil) return false;
const threshold = Number.isFinite(Number(thresholdPx))
? Math.max(0, Number(thresholdPx))
: CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX;
if (!isNearBottom(threshold)) return false;
// detached 是用户明确上滑后的阅读状态。布局变化、流式增高和模式切换
// 即使让视口暂时接近底部,也不能自行恢复;只有用户明确向下滚到底才恢复。
if (scrollMode === 'detached') {
if (!userInitiated) return false;
setScrollFollowing();
}
return true;
}
function captureScrollPinState() {
if (Date.now() < detachLockUntil) return false;
return scrollMode === 'following';
}
function setScrollFollowing() {
scrollMode = 'following';
detachLockUntil = 0;
userScrollIntentUntil = 0;
hasPendingNewBelow = false;
updateTurnRailState();
}
function markPendingNewBelow() {
if (scrollMode !== 'detached') return;
hasPendingNewBelow = true;
updateTurnRailState();
}
function setScrollDetached() {
scrollMode = 'detached';
detachLockUntil = Date.now() + DETACH_LOCK_MS;
cancelAnimationFrame(scrollFollowRaf);
if (isStreamActive()) {
hasPendingNewBelow = true;
}
updateTurnRailState();
}
function scrollChatToBottomInstant() {
if (scrollMode !== 'following') return;
const el = getChatMessagesEl();
if (!el) return;
programmaticScroll = true;
el.scrollTop = el.scrollHeight;
lastScrollTop = el.scrollTop;
lastScrollHeight = el.scrollHeight;
requestAnimationFrame(function () {
programmaticScroll = false;
});
}
function scrollChatToBottomSmooth() {
const el = getChatMessagesEl();
if (!el) return;
programmaticScroll = true;
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
requestAnimationFrame(function () {
programmaticScroll = false;
const node = getChatMessagesEl();
if (node) {
lastScrollTop = node.scrollTop;
lastScrollHeight = node.scrollHeight;
}
});
}
function updateTurnRailState() {
updateTurnRailActive();
updateReturnLatestButton();
}
function updateReturnLatestButton() {
const button = getReturnLatestButton();
const messagesEl = getChatMessagesEl();
if (!button || !messagesEl) return;
const scrollable = messagesEl.scrollHeight > messagesEl.clientHeight + 2;
const shouldShow = scrollable && !isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX);
const streaming = shouldShow && isStreamActive();
button.hidden = !shouldShow;
button.classList.toggle('is-streaming', streaming);
button.classList.toggle('has-pending-new', shouldShow && hasPendingNewBelow);
}
function isolateReturnLatestPointerEvent(event) {
if (!event) return;
// 该按钮会在点击后立即隐藏。阻止指针事件继续冒泡,避免长历史对话中
// 按钮隐藏与底部审批卡片重排发生在同一帧时产生点击穿透。
event.stopPropagation();
}
function onReturnLatestClick(event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
forceScrollChatToBottom(true);
const button = getReturnLatestButton();
if (button) {
button.hidden = true;
button.blur();
}
}
function canAutoScrollNow(wasPinnedBeforeDomUpdate) {
if (Date.now() < detachLockUntil) return false;
if (scrollMode === 'detached') return false;
if (wasPinnedBeforeDomUpdate === true) return true;
return isNearBottom(CHAT_SCROLL_FOLLOW_THRESHOLD_PX);
}
function scheduleChatScrollToBottomIfFollowing(wasPinnedBeforeDomUpdate) {
if (!canAutoScrollNow(wasPinnedBeforeDomUpdate)) {
markPendingNewBelow();
return;
}
cancelAnimationFrame(scrollFollowRaf);
scrollFollowRaf = requestAnimationFrame(scrollChatToBottomInstant);
}
/**
* 长详情恢复/终态对账会跨多个 requestAnimationFrame 分批增高 DOM。
* 单次滚底可能早于最后一批节点;在仍处于 following 时连续若干帧校准,
* 用户一旦主动上滑进入 detached,后续帧立即停止,避免抢回阅读位置。
*/
function settleChatToBottomIfFollowing(frameCount) {
const frames = Number.isFinite(Number(frameCount))
? Math.max(1, Math.min(30, Math.floor(Number(frameCount))))
: 12;
const generation = ++scrollSettleGeneration;
function settleFrame(remaining) {
if (generation !== scrollSettleGeneration) return;
if (scrollMode !== 'following' || Date.now() < detachLockUntil) return;
scrollChatToBottomInstant();
if (remaining > 1) {
requestAnimationFrame(function () {
settleFrame(remaining - 1);
});
}
}
requestAnimationFrame(function () {
settleFrame(frames);
});
}
/**
* 刷新恢复长会话时,消息、详情和审批卡会跨多帧继续增高。
* 进入恢复流程时明确回到 following;用户随后若主动上滑,既有输入监听会立即
* 切换为 detached,并使后续校准帧停止,不会抢回阅读位置。
*/
function settleConversationRestoreToBottom(frameCount) {
setScrollFollowing();
const requestedFrames = Number.isFinite(Number(frameCount))
? Math.max(1, Math.floor(Number(frameCount)))
: 30;
const minimumDuration = Math.max(
CONVERSATION_RESTORE_SETTLE_MIN_MS,
Math.ceil(requestedFrames * (1000 / 60))
);
const generation = ++conversationRestoreGeneration;
const startedAt = Date.now();
let lastHeight = -1;
let stableFrames = 0;
function settleRestoreFrame() {
if (generation !== conversationRestoreGeneration) return;
// wheel / touch / keyboard / scrollbar drag 会进入 detached;立即尊重用户阅读位置。
if (scrollMode !== 'following' || Date.now() < detachLockUntil) return;
const el = getChatMessagesEl();
if (!el) return;
scrollChatToBottomInstant();
const currentHeight = el.scrollHeight;
if (currentHeight === lastHeight && isNearBottom(1)) {
stableFrames += 1;
} else {
stableFrames = 0;
}
lastHeight = currentHeight;
const elapsed = Date.now() - startedAt;
const reachedStableMinimum = elapsed >= minimumDuration
&& stableFrames >= CONVERSATION_RESTORE_STABLE_FRAMES;
if (!reachedStableMinimum && elapsed < CONVERSATION_RESTORE_SETTLE_MAX_MS) {
requestAnimationFrame(settleRestoreFrame);
}
}
requestAnimationFrame(settleRestoreFrame);
}
/** @param {boolean} wasPinned DOM 更新前是否应跟随(由 captureScrollPinState 传入) */
function scrollChatMessagesToBottomIfPinned(wasPinned) {
scheduleChatScrollToBottomIfFollowing(wasPinned);
}
function forceScrollChatToBottom(smooth) {
setScrollFollowing();
cancelAnimationFrame(scrollFollowRaf);
if (smooth) {
scrollChatToBottomSmooth();
} else {
scrollChatToBottomInstant();
}
}
function onUserSendMessage() {
setScrollFollowing();
scrollChatToBottomInstant();
}
function clearAllStreamingMarkers() {
document.querySelectorAll('.progress-container.is-streaming, .process-details-container.is-streaming').forEach(function (el) {
el.classList.remove('is-streaming');
});
}
function markProgressStreaming(active, progressId) {
if (!active) {
clearAllStreamingMarkers();
return;
}
if (!progressId) return;
const progressEl = document.getElementById(progressId);
const container = progressEl && progressEl.querySelector('.progress-container');
if (container) container.classList.add('is-streaming');
}
function markProcessDetailsStreaming(active, assistantDomId) {
if (!active) {
document.querySelectorAll('.process-details-container.is-streaming').forEach(function (el) {
el.classList.remove('is-streaming');
});
return;
}
if (!assistantDomId) return;
const container = document.getElementById('process-details-' + assistantDomId);
if (!container) return;
container.classList.add('is-streaming');
const timeline = container.querySelector('.progress-timeline');
if (timeline) timeline.classList.add('expanded');
}
function onStreamEnd() {
clearAllStreamingMarkers();
try {
window.__csTaskEventStream = { active: false, conversationId: null, assistantDomId: null, progressId: null };
} catch (e) { /* ignore */ }
scheduleTurnRailRefresh(true);
updateTurnRailState();
}
/** 刷新后会话 task-events 补流开始时,与 sendMessage 主流程对齐 */
function onTaskEventStreamBegin(conversationId, assistantDomId, progressId) {
try {
window.__csTaskEventStream = {
active: true,
conversationId: conversationId || null,
assistantDomId: assistantDomId || null,
progressId: progressId || null
};
} catch (e) { /* ignore */ }
markProcessDetailsStreaming(true, assistantDomId);
resumeFollowingIfAtBottom();
scheduleTurnRailRefresh();
updateTurnRailState();
}
function onTaskEventStreamEnd() {
onStreamEnd();
}
function applyMessageScrollOption(options) {
scheduleTurnRailRefresh();
const opt = (options && options.scroll) || 'follow';
if (opt === 'none') return;
if (opt === 'force') {
forceScrollChatToBottom(false);
return;
}
scheduleChatScrollToBottomIfFollowing(captureScrollPinState());
}
/** 流式/用户未跟随时禁止 scrollIntoView 抢滚动 */
function scrollElementIntoViewIfFollowing(el, options) {
if (!el || !captureScrollPinState()) return;
el.scrollIntoView(options || { behavior: 'smooth', block: 'nearest' });
}
function onChatMessagesScroll() {
const el = getChatMessagesEl();
if (!el) return;
const st = el.scrollTop;
const sh = el.scrollHeight;
const hasUserScrollIntent = Date.now() <= userScrollIntentUntil;
if (programmaticScroll) {
// 正在执行恢复/流式粘底时,用户仍可能反向滚轮或拖动滚动条。
// 脚本滚底只会让 scrollTop 增大;此处出现减小必定是用户在中断跟随。
if (st < lastScrollTop - 1 && (scrollMode === 'detached' || hasUserScrollIntent)) {
setScrollDetached();
}
lastScrollTop = st;
lastScrollHeight = sh;
updateTurnRailState();
return;
}
const scrolledUp = st < lastScrollTop - 1;
const scrolledDown = st > lastScrollTop + 1;
const contentShrank = sh < lastScrollHeight - 1;
// 刷新/终态重绘会先清空或折叠旧 DOM,浏览器会被动把 scrollTop 压小。
// 这不是用户上滑,不应错误退出 following。
if (contentShrank) {
lastScrollTop = st;
lastScrollHeight = sh;
updateTurnRailState();
return;
}
// 刷新恢复会重建消息和详情,滚动锚定可能在没有用户输入时让 scrollTop
// 暂时减小。只有明确的滚轮、触控、键盘或滚动条意图才解除粘底。
if (scrolledUp && (scrollMode === 'detached' || hasUserScrollIntent)) {
setScrollDetached();
} else if (
scrolledDown &&
hasUserScrollIntent &&
resumeFollowingIfAtBottom(CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX, true)
) {
// 仅在用户明确向下滚动并到达真实底部时恢复跟随,不主动改写 scrollTop。
// 后续新增内容再按 following 状态自然粘底,避免接近底部时突然跳动。
}
lastScrollTop = st;
lastScrollHeight = sh;
updateTurnRailState();
}
function bindChatScrollListeners() {
if (listenersBound) return;
const el = getChatMessagesEl();
if (!el) return;
listenersBound = true;
lastScrollTop = el.scrollTop;
lastScrollHeight = el.scrollHeight;
el.addEventListener('wheel', function (e) {
if (Math.abs(e.deltaY) > 1) {
userScrollIntentUntil = Date.now() + 1200;
}
if (e.deltaY < -1) {
setScrollDetached();
}
}, { passive: true });
// 拖动原生纵向滚动条不会产生 wheel;先记录指针意图,再由 scroll 事件确认方向。
el.addEventListener('pointerdown', function (e) {
const rect = el.getBoundingClientRect();
if (e.clientX >= rect.right - 18) {
userScrollIntentUntil = Date.now() + 1800;
}
}, { passive: true });
el.addEventListener('keydown', function (e) {
const scrollKeys = ['ArrowUp', 'PageUp', 'Home', 'ArrowDown', 'PageDown', 'End', ' '];
if (scrollKeys.includes(e.key)) {
userScrollIntentUntil = Date.now() + 1200;
}
if (e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'Home' || (e.key === ' ' && e.shiftKey)) {
setScrollDetached();
}
});
el.addEventListener('touchmove', function (e) {
if (e.touches && e.touches.length === 1) {
userScrollIntentUntil = Date.now() + 1200;
el._csTouchLastY = el._csTouchLastY != null ? el._csTouchLastY : e.touches[0].clientY;
if (e.touches[0].clientY > el._csTouchLastY + 4) {
setScrollDetached();
}
el._csTouchLastY = e.touches[0].clientY;
}
}, { passive: true });
el.addEventListener('touchstart', function (e) {
if (e.touches && e.touches.length) {
el._csTouchLastY = e.touches[0].clientY;
}
}, { passive: true });
el.addEventListener('touchend', function () {
el._csTouchLastY = null;
}, { passive: true });
el.addEventListener('scroll', onChatMessagesScroll, { passive: true });
const returnLatestButton = getReturnLatestButton();
if (returnLatestButton) {
returnLatestButton.addEventListener('pointerdown', isolateReturnLatestPointerEvent);
returnLatestButton.addEventListener('pointerup', isolateReturnLatestPointerEvent);
returnLatestButton.addEventListener('click', onReturnLatestClick);
}
const turnPreview = document.getElementById('chat-turn-rail-preview');
if (turnPreview) {
turnPreview.addEventListener('mouseenter', function () {
if (turnPreviewHideTimer) {
window.clearTimeout(turnPreviewHideTimer);
turnPreviewHideTimer = 0;
}
});
turnPreview.addEventListener('mouseleave', scheduleHideTurnPreview);
}
if (typeof MutationObserver === 'function') {
turnRailObserver = new MutationObserver(function () {
scheduleTurnRailRefresh();
// 最终回复会替换消息气泡内部 HTML,任务详情也会在子树内持续增高。
// 只在仍处于 following 时按帧合并粘底;用户上滑后的 detached 状态不受影响。
if (scrollMode === 'following' && Date.now() >= detachLockUntil) {
scheduleChatScrollToBottomIfFollowing(true);
}
});
turnRailObserver.observe(el, { childList: true, subtree: true, characterData: true });
}
if (typeof ResizeObserver === 'function') {
chatMessagesResizeObserver = new ResizeObserver(function () {
// 顶部运行任务条、输入框或视口变化会改变消息区 clientHeight
// 但不会触发消息子树 MutationObserver。跟随模式下需重新精确粘底。
if (scrollMode === 'following' && Date.now() >= detachLockUntil) {
scheduleChatScrollToBottomIfFollowing(true);
} else {
updateTurnRailState();
}
});
chatMessagesResizeObserver.observe(el);
}
window.addEventListener('resize', function () {
hideTurnPreview();
if (scrollMode === 'following' && Date.now() >= detachLockUntil) {
scheduleChatScrollToBottomIfFollowing(true);
} else {
updateTurnRailState();
}
}, { passive: true });
}
function initChatScroll() {
bindChatScrollListeners();
const el = getChatMessagesEl();
if (el) {
lastScrollTop = el.scrollTop;
lastScrollHeight = el.scrollHeight;
}
scheduleTurnRailRefresh(true);
updateTurnRailState();
}
window.CyberStrikeChatScroll = {
init: initChatScroll,
onUserSendMessage: onUserSendMessage,
onStreamEnd: onStreamEnd,
onTaskEventStreamBegin: onTaskEventStreamBegin,
onTaskEventStreamEnd: onTaskEventStreamEnd,
captureScrollPinState: captureScrollPinState,
scheduleScroll: scheduleChatScrollToBottomIfFollowing,
scrollIfPinned: scrollChatMessagesToBottomIfPinned,
settleToBottomIfFollowing: settleChatToBottomIfFollowing,
settleConversationRestoreToBottom: settleConversationRestoreToBottom,
forceScrollToBottom: forceScrollChatToBottom,
applyMessageScroll: applyMessageScrollOption,
scrollIntoViewIfFollowing: scrollElementIntoViewIfFollowing,
isPinnedToBottom: isChatMessagesPinnedToBottom,
markProgressStreaming: markProgressStreaming,
markProcessDetailsStreaming: markProcessDetailsStreaming,
setScrollFollowing: setScrollFollowing,
setScrollDetached: setScrollDetached,
refreshReturnLatest: updateReturnLatestButton,
refreshTurnRail: function () { scheduleTurnRailRefresh(true); },
};
window.isChatMessagesPinnedToBottom = isChatMessagesPinnedToBottom;
window.captureScrollPinState = captureScrollPinState;
window.scrollChatMessagesToBottomIfPinned = scrollChatMessagesToBottomIfPinned;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initChatScroll);
} else {
initChatScroll();
}
})();