优化项目对话、刷新续流、实时滚动与工具状态恢复 (#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): 修复刷新后流式输出停止粘底
This commit is contained in:
RuoJi6
2026-08-13 21:28:37 +08:00
committed by GitHub
parent b170f2c4b1
commit eb6bab574f
31 changed files with 10763 additions and 886 deletions
+549 -49
View File
@@ -7,32 +7,322 @@
/** 距底部在此范围内才继续自动跟随(宜小,避免“差一点也被拽回去”) */
const CHAT_SCROLL_FOLLOW_THRESHOLD_PX = 48;
/** FAB 隐藏:用户已手动滚近底部 */
const CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX = 120;
/** 只有真正到达底部才恢复跟随;2px 用于兼容高分屏的亚像素滚动。 */
const CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX = 2;
/** 到达此范围视为位于最后一轮 */
const CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX = 120;
/** 用户上滑后的短暂锁,防止 SSE 与 scroll 事件竞态抢滚动 */
const DETACH_LOCK_MS = 280;
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');
}
/** 主 POST 流 + 刷新后 task-events 补流均视为「流式进行中」 */
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 (live && live.active) return true;
if (streamBelongsToVisibleConversation(live)) return true;
const replay = window.__csTaskEventStream;
return !!(replay && replay.active);
return streamBelongsToVisibleConversation(replay);
} catch (e) {
return false;
}
@@ -51,34 +341,42 @@
}
function isChatMessagesPinnedToBottom() {
return isNearBottom(CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX);
return isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX);
}
/** 已在底部时恢复 following(解决:手动滚到底但 scrollMode 仍为 detached */
function resumeFollowingIfAtBottom() {
if (Date.now() < detachLockUntil) return false;
if (!isNearBottom(CHAT_SCROLL_FOLLOW_THRESHOLD_PX)) return false;
if (scrollMode === 'detached') setScrollFollowing();
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;
if (resumeFollowingIfAtBottom()) return true;
return scrollMode === 'following';
}
function setScrollFollowing() {
scrollMode = 'following';
detachLockUntil = 0;
userScrollIntentUntil = 0;
hasPendingNewBelow = false;
updateScrollToBottomFab();
updateTurnRailState();
}
function markPendingNewBelow() {
if (scrollMode !== 'detached') return;
hasPendingNewBelow = true;
updateScrollToBottomFab();
updateTurnRailState();
}
function setScrollDetached() {
@@ -88,7 +386,7 @@
if (isStreamActive()) {
hasPendingNewBelow = true;
}
updateScrollToBottomFab();
updateTurnRailState();
}
function scrollChatToBottomInstant() {
@@ -98,6 +396,7 @@
programmaticScroll = true;
el.scrollTop = el.scrollHeight;
lastScrollTop = el.scrollTop;
lastScrollHeight = el.scrollHeight;
requestAnimationFrame(function () {
programmaticScroll = false;
});
@@ -111,34 +410,52 @@
requestAnimationFrame(function () {
programmaticScroll = false;
const node = getChatMessagesEl();
if (node) lastScrollTop = node.scrollTop;
if (node) {
lastScrollTop = node.scrollTop;
lastScrollHeight = node.scrollHeight;
}
});
}
function updateScrollToBottomFab() {
const fab = document.getElementById('chat-scroll-to-bottom');
if (!fab) return;
function updateTurnRailState() {
updateTurnRailActive();
updateReturnLatestButton();
}
const show = scrollMode === 'detached' && !isNearBottom(CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX);
fab.classList.toggle('visible', show);
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);
}
let label;
if (hasPendingNewBelow) {
label = typeof window.t === 'function'
? window.t('chat.scrollToBottomHasNew')
: '↓ 有新内容';
} else {
label = typeof window.t === 'function'
? window.t('chat.scrollToBottom')
: '回到底部';
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();
}
fab.setAttribute('aria-label', label);
fab.textContent = label;
}
function canAutoScrollNow(wasPinnedBeforeDomUpdate) {
if (Date.now() < detachLockUntil) return false;
if (resumeFollowingIfAtBottom()) return true;
if (scrollMode === 'detached') return false;
if (wasPinnedBeforeDomUpdate === true) return true;
return isNearBottom(CHAT_SCROLL_FOLLOW_THRESHOLD_PX);
@@ -153,6 +470,79 @@
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);
@@ -210,7 +600,8 @@
try {
window.__csTaskEventStream = { active: false, conversationId: null, assistantDomId: null, progressId: null };
} catch (e) { /* ignore */ }
updateScrollToBottomFab();
scheduleTurnRailRefresh(true);
updateTurnRailState();
}
/** 刷新后会话 task-events 补流开始时,与 sendMessage 主流程对齐 */
@@ -225,7 +616,8 @@
} catch (e) { /* ignore */ }
markProcessDetailsStreaming(true, assistantDomId);
resumeFollowingIfAtBottom();
updateScrollToBottomFab();
scheduleTurnRailRefresh();
updateTurnRailState();
}
function onTaskEventStreamEnd() {
@@ -233,6 +625,7 @@
}
function applyMessageScrollOption(options) {
scheduleTurnRailRefresh();
const opt = (options && options.scroll) || 'follow';
if (opt === 'none') return;
if (opt === 'force') {
@@ -252,22 +645,51 @@
const el = getChatMessagesEl();
if (!el) return;
const st = el.scrollTop;
const sh = el.scrollHeight;
const hasUserScrollIntent = Date.now() <= userScrollIntentUntil;
if (programmaticScroll) {
lastScrollTop = el.scrollTop;
// 正在执行恢复/流式粘底时,用户仍可能反向滚轮或拖动滚动条。
// 脚本滚底只会让 scrollTop 增大;此处出现减小必定是用户在中断跟随。
if (st < lastScrollTop - 1 && (scrollMode === 'detached' || hasUserScrollIntent)) {
setScrollDetached();
}
lastScrollTop = st;
lastScrollHeight = sh;
updateTurnRailState();
return;
}
const st = el.scrollTop;
const scrolledUp = st < lastScrollTop - 1;
const scrolledDown = st > lastScrollTop + 1;
const contentShrank = sh < lastScrollHeight - 1;
if (scrolledUp) {
// 刷新/终态重绘会先清空或折叠旧 DOM,浏览器会被动把 scrollTop 压小。
// 这不是用户上滑,不应错误退出 following。
if (contentShrank) {
lastScrollTop = st;
lastScrollHeight = sh;
updateTurnRailState();
return;
}
// 刷新恢复会重建消息和详情,滚动锚定可能在没有用户输入时让 scrollTop
// 暂时减小。只有明确的滚轮、触控、键盘或滚动条意图才解除粘底。
if (scrolledUp && (scrollMode === 'detached' || hasUserScrollIntent)) {
setScrollDetached();
} else if (resumeFollowingIfAtBottom()) {
/* 拖滚动条/点击轨道跳到底部时也恢复跟随 */
} else if (
scrolledDown &&
hasUserScrollIntent &&
resumeFollowingIfAtBottom(CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX, true)
) {
// 仅在用户明确向下滚动并到达真实底部时恢复跟随,不主动改写 scrollTop。
// 后续新增内容再按 following 状态自然粘底,避免接近底部时突然跳动。
}
lastScrollTop = st;
updateScrollToBottomFab();
lastScrollHeight = sh;
updateTurnRailState();
}
function bindChatScrollListeners() {
@@ -276,13 +698,38 @@
if (!el) return;
listenersBound = true;
lastScrollTop = el.scrollTop;
lastScrollHeight = el.scrollHeight;
el.addEventListener('wheel', function (e) {
if (e.deltaY < -1) setScrollDetached();
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();
@@ -301,19 +748,68 @@
el.addEventListener('scroll', onChatMessagesScroll, { passive: true });
const fab = document.getElementById('chat-scroll-to-bottom');
if (fab) {
fab.addEventListener('click', function () {
forceScrollChatToBottom(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;
updateScrollToBottomFab();
if (el) {
lastScrollTop = el.scrollTop;
lastScrollHeight = el.scrollHeight;
}
scheduleTurnRailRefresh(true);
updateTurnRailState();
}
window.CyberStrikeChatScroll = {
@@ -325,6 +821,8 @@
captureScrollPinState: captureScrollPinState,
scheduleScroll: scheduleChatScrollToBottomIfFollowing,
scrollIfPinned: scrollChatMessagesToBottomIfPinned,
settleToBottomIfFollowing: settleChatToBottomIfFollowing,
settleConversationRestoreToBottom: settleConversationRestoreToBottom,
forceScrollToBottom: forceScrollChatToBottom,
applyMessageScroll: applyMessageScrollOption,
scrollIntoViewIfFollowing: scrollElementIntoViewIfFollowing,
@@ -333,6 +831,8 @@
markProcessDetailsStreaming: markProcessDetailsStreaming,
setScrollFollowing: setScrollFollowing,
setScrollDetached: setScrollDetached,
refreshReturnLatest: updateReturnLatestButton,
refreshTurnRail: function () { scheduleTurnRailRefresh(true); },
};
window.isChatMessagesPinnedToBottom = isChatMessagesPinnedToBottom;