Merge pull request #396 from Sycun/codex/fix-long-chat-scroll

fix(chat): stabilize scrolling in long streaming conversations
This commit is contained in:
公明 authored and GitHub committed 2026-11-13 11:10:39 +08:00
commit 2a7f13b970
8 files changed
+382 -221

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
/* Offline regression. Requires Playwright and Chrome (or CHROME_PATH).
* Run: node --test scripts/chat-scroll-browser.test.cjs
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const { chromium } = require('playwright');
const read = file => fs.readFileSync(path.join(__dirname, '..', 'web/static', file), 'utf8');
async function scrollState(page) {
return page.evaluate(() => {
const chat = document.getElementById('chat-messages');
const timeline = document.querySelector('.progress-timeline');
return {
outerGap: chat.scrollHeight - chat.clientHeight - chat.scrollTop,
innerGap: timeline.scrollHeight - timeline.clientHeight - timeline.scrollTop,
innerTop: timeline.scrollTop,
outerFollowing: window.captureScrollPinState(),
pruned: !!timeline.querySelector('.timeline-live-pruned-marker')
};
});
}
test('long live and restored conversations stay at the latest iteration without stealing manual scroll', async () => {
const browser = await chromium.launch({
...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}),
});
try {
for (const width of [1440, 390]) {
for (const restored of [false, true]) {
const label = `${width}px ${restored ? 'restored' : 'live'}`;
const page = await browser.newPage({ viewport: { width, height: 820 } });
try {
await page.route('**/*', route => route.request().url() === 'http://chat.test/'
? route.fulfill({
contentType: 'text/html',
body: `<!doctype html><html data-theme="dark"><body>
<div class="chat-container" style="height: 700px">
<div id="chat-messages" class="chat-messages"></div>
<button id="chat-return-latest" hidden></button>
</div></body></html>`
})
: route.abort());
await page.goto('http://chat.test/');
for (const file of ['style.css', 'mobile.css']) {
await page.addStyleTag({ content: read('css/' + file) });
}
// Supply only the chat globals required by the real progress renderer.
await page.addScriptTag({ content: `
let messageCounter = 0, currentConversationId = '';
const escapeHtml = text => escapeHtmlLocal(text);
` });
for (const file of ['chat-scroll.js', 'monitor.js']) {
await page.addScriptTag({ content: read('js/' + file) });
}
await page.evaluate(async restored => {
const chat = document.getElementById('chat-messages');
for (let i = 0; i < 30; i++) {
const row = document.createElement('div');
row.className = 'message user';
row.innerHTML = '<div class="message-bubble">Synthetic history</div>';
chat.append(row);
}
if (restored) {
chat.insertAdjacentHTML('beforeend', `
<div class="message assistant assistant-turn-with-process">
<div class="message-content">
<div id="process-details-restored" class="process-details-container is-streaming">
<div class="process-details-content"><div class="progress-timeline expanded"></div></div>
</div>
</div>
</div>`);
startProcessDetailsLatestFollow('restored');
} else {
addProgressMessage();
}
const timeline = document.querySelector('.progress-timeline');
for (let i = 0; i < (restored ? 50 : 156); i++) {
addTimelineItem(timeline, 'reasoning_chain', {
title: 'Synthetic reasoning ' + i,
message: 'Example reasoning text.\n'.repeat(i % 11 === 0 ? 65 : i % 9 + 1)
});
if (i % 10 === 0) await new Promise(requestAnimationFrame);
}
}, restored);
await page.waitForFunction(() => {
const tl = document.querySelector('.progress-timeline');
const chat = document.getElementById('chat-messages');
return tl.scrollHeight - tl.clientHeight - tl.scrollTop <= 2 &&
chat.scrollHeight - chat.clientHeight - chat.scrollTop <= 2;
}, null, { timeout: 2000 });
const latest = await scrollState(page);
assert.equal(latest.pruned, !restored, label);
const box = await page.locator('.progress-timeline').boundingBox();
await page.mouse.move(box.x + 40, box.y + box.height / 2);
await page.mouse.wheel(0, -420);
await page.waitForFunction(() => processDetailsDistanceFromLatest(
document.querySelector('.progress-timeline')) > 100);
const reading = await scrollState(page);
assert.equal(reading.outerFollowing, true, label);
assert.ok(reading.outerGap <= 2, label);
if (restored) {
// Upgrading the restore follower to the live subscription preserves reading position.
await page.evaluate(() => startProcessDetailsLatestFollow('restored', { persistent: true }));
}
await page.evaluate(async () => {
const row = document.querySelector('.progress-timeline .timeline-item:last-child .timeline-item-content');
scheduleStreamPlainTextUpdate(row, row.textContent + 'A new line.\n'.repeat(20));
await new Promise(requestAnimationFrame);
await new Promise(requestAnimationFrame);
await new Promise(requestAnimationFrame);
});
const afterUpdate = await scrollState(page);
assert.equal(afterUpdate.innerTop, reading.innerTop, label);
assert.ok(afterUpdate.innerGap > reading.innerGap, label);
if (restored) {
await page.locator('.process-details-return-latest').click();
} else {
await page.mouse.wheel(0, 100000);
}
await page.waitForFunction(() => processDetailsDistanceFromLatest(
document.querySelector('.progress-timeline')) <= 2);
await page.evaluate(async () => {
const timeline = document.querySelector('.progress-timeline');
addTimelineItem(timeline, 'reasoning_chain', {
title: 'Latest iteration', message: 'More output.\n'.repeat(50)
});
await new Promise(requestAnimationFrame);
await new Promise(requestAnimationFrame);
await new Promise(requestAnimationFrame);
});
const atBottom = await scrollState(page);
assert.ok(atBottom.innerGap <= 2 && atBottom.outerGap <= 2, label);
} finally {
await page.close();
}
}
}
} finally {
await browser.close();
}
});
+11
View File
@@ -3514,6 +3514,12 @@ html[data-theme="dark"] .sidebar-content:hover::-webkit-scrollbar-thumb:hover {
scrollbar-color: color-mix(in srgb, var(--text-muted) 55%, transparent) transparent;
}
/* 贴底时由滚动控制器定位;浏览历史时保留浏览器的锚定。 */
#chat-messages.is-following-latest,
#chat-messages .progress-timeline.is-following-latest {
overflow-anchor: none;
}
.chat-messages::-webkit-scrollbar {
width: 4px;
height: 4px;
@@ -7950,6 +7956,11 @@ html[data-theme="dark"] .login-card .login-submit:disabled {
contain-intrinsic-size: auto 72px;
}
/* 跟随中的时间线使用实际高度,避免滚到底后才展开估算高度并把最新内容推出视口。 */
#chat-messages .progress-timeline.has-latest-follow > .timeline-item {
content-visibility: visible;
}
.timeline-live-pruned-marker {
position: sticky;
top: 0;
+60 -17
View File
@@ -23,6 +23,7 @@ function functionSource(source, name, nextName) {
function createScrollRuntime() {
const listeners = new Map();
const buttonListeners = new Map();
const scrollCalls = [];
const classList = { add() {}, remove() {}, toggle() {}, contains() { return false; } };
const chatEl = {
scrollTop: 500,
@@ -31,7 +32,11 @@ function createScrollRuntime() {
children: [],
classList,
addEventListener(type, handler) { listeners.set(type, handler); },
scrollTo(options) { this.scrollTop = Number(options && options.top) || 0; },
scrollTo(options) {
scrollCalls.push(options);
if (options.behavior === 'smooth') return;
this.scrollTop = Math.min(Math.max(0, options.top), this.scrollHeight - this.clientHeight);
},
getBoundingClientRect() { return { right: 1000 }; },
};
const returnLatest = {
@@ -83,6 +88,8 @@ function createScrollRuntime() {
chatEl,
returnLatest,
listeners,
buttonListeners,
scrollCalls,
flushAnimationFrames() {
while (rafQueue.size) {
const pending = Array.from(rafQueue.values());
@@ -118,7 +125,7 @@ test('向上滚动立即解除粘底,只有滚到真实底部才恢复', () =>
runtime.chatEl.scrollHeight = 1200;
runtime.api.scrollIfPinned(true);
runtime.flushAnimationFrames();
assert.equal(runtime.chatEl.scrollTop, 1200, '恢复后新增输出继续请求滚到最底部');
assert.equal(runtime.chatEl.scrollTop, 700, '恢复后新增输出继续请求滚到最底部');
});
test('用户离开最新位置后回到底部按钮稳定显示到真实底部', () => {
@@ -140,6 +147,7 @@ test('刷新重建详情引起的布局上移不会误判为用户上滑', () =>
const runtime = createScrollRuntime();
runtime.flushAnimationFrames();
runtime.chatEl.scrollHeight = 960;
runtime.chatEl.scrollTop = 460;
runtime.listeners.get('scroll')();
assert.equal(runtime.api.captureScrollPinState(), true, '没有用户输入的布局滚动仍应保持跟随');
@@ -147,7 +155,47 @@ test('刷新重建详情引起的布局上移不会误判为用户上滑', () =>
runtime.chatEl.scrollHeight = 1100;
runtime.api.scrollIfPinned(true);
runtime.flushAnimationFrames();
assert.equal(runtime.chatEl.scrollTop, 1100, '刷新恢复后的后续增量应继续粘底');
assert.equal(runtime.chatEl.scrollTop, 600, '刷新恢复后的后续增量应继续粘底');
});
test('长对话点击回到最新立即到达真实底部,并继续跟随新增输出', () => {
const runtime = createScrollRuntime();
runtime.chatEl.scrollHeight = 150000;
runtime.api.forceScrollToBottom();
runtime.chatEl.scrollTop = 1200;
runtime.listeners.get('scroll')();
assert.equal(runtime.returnLatest.hidden, false);
runtime.buttonListeners.get('click')({ preventDefault() {}, stopPropagation() {} });
assert.equal(runtime.scrollCalls.at(-1).behavior, 'instant');
assert.equal(runtime.chatEl.scrollTop, 149500);
assert.equal(runtime.returnLatest.hidden, true);
runtime.chatEl.scrollHeight += 250;
runtime.api.scrollIfPinned(true);
runtime.flushAnimationFrames();
assert.equal(runtime.chatEl.scrollTop, 149750);
});
test('原生滚动条未派发指针事件时仍可中断粘底,历史渲染结束后保留阅读位置', () => {
const runtime = createScrollRuntime();
runtime.chatEl.scrollHeight = 150000;
runtime.api.forceScrollToBottom();
runtime.api.settleToBottomIfFollowing(24);
runtime.chatEl.scrollTop = 70000;
runtime.chatEl.scrollHeight += 200;
runtime.listeners.get('scroll')();
assert.equal(runtime.api.captureScrollPinState(), false);
runtime.api.settleConversationRestoreToBottom(30);
runtime.api.scrollIfPinned(true);
runtime.flushAnimationFrames();
assert.equal(runtime.chatEl.scrollTop, 70000);
runtime.chatEl.scrollTop = 149700;
runtime.listeners.get('scroll')();
assert.equal(runtime.api.captureScrollPinState(), true);
});
test('登录成功后重新加载曾因未授权失败的项目侧栏', () => {
@@ -179,10 +227,7 @@ test('用户真正滑到底部后恢复自动跟随且不会提前强制跳底',
assert.doesNotMatch(scrollSource, /else if \(resumeFollowingIfAtBottom\(\)\)/);
assert.doesNotMatch(scrollSource, /scheduleChatScrollToBottomIfFollowing\(true\)/);
assert.doesNotMatch(scrollSource, /else if \(resumeFollowingIfAtBottom\(\)\)/);
assert.match(scrollSource, /contentShrank/);
assert.match(scrollSource, /sh < lastScrollHeight - 1/);
assert.match(scrollSource, /if \(scrolledUp && \(scrollMode === 'detached' \|\| hasUserScrollIntent\)\) \{[\s\S]*?setScrollDetached\(\)/);
assert.match(scrollSource, /if \(programmaticScroll\) \{[\s\S]*?st < lastScrollTop - 1 && \(scrollMode === 'detached' \|\| hasUserScrollIntent\)[\s\S]*?setScrollDetached\(\)/);
assert.match(scrollSource, /if \(scrolledUp && hasUserScrollIntent\) \{[\s\S]*?setScrollDetached\(\)/);
});
test('切换对话模式引起的布局滚动不会重新开启粘底', () => {
@@ -191,8 +236,7 @@ test('切换对话模式引起的布局滚动不会重新开启粘底', () => {
const selectModeSource = functionSource(chat, 'selectAgentMode', 'initChatAgentModeFromConfig');
assert.match(scroll, /let userScrollIntentUntil = 0/);
assert.match(scrollSource, /const hasUserScrollIntent = Date\.now\(\) <= userScrollIntentUntil/);
assert.match(scrollSource, /scrolledDown &&[\s\S]*?hasUserScrollIntent &&[\s\S]*?resumeFollowingIfAtBottom/);
assert.match(scrollSource, /scrolledDown && hasUserScrollIntent[\s\S]*?resumeFollowingIfAtBottom/);
assert.doesNotMatch(scrollSource, /else if \(resumeFollowingIfAtBottom\(\)\)/);
assert.match(bindSource, /Math\.abs\(e\.deltaY\) > 1/);
assert.match(bindSource, /userScrollIntentUntil = Date\.now\(\) \+ 1800/);
@@ -234,22 +278,22 @@ test('刷新后迭代思考区独立跟随最新内容且允许用户上滑解
assert.match(monitor, /function getProcessDetailsLatestFollowStateForTimeline\(timeline\)/);
assert.match(monitor, /const followingLatest = !!\(followState && !followState\.detached\)/);
assert.match(monitor, /const shouldShow = !followingLatest && expanded && scrollable && awayFromLatest/);
assert.match(returnLatestSource, /timeline\.scrollTo\(\{ top: targetTop, behavior: 'smooth' \}\)/);
assert.match(returnLatestSource, /timeline\.scrollTo\(\{ top: timeline\.scrollHeight, behavior: 'instant' \}\)/);
assert.match(returnLatestSource, /timeline\.addEventListener\('scroll', onScroll/);
assert.match(returnLatestSource, /window\.ensureProcessDetailsReturnLatestControl = ensureProcessDetailsReturnLatestControl/);
assert.match(startSource, /new MutationObserver\(scheduleFollowLatest\)/);
assert.match(startSource, /characterData: true/);
assert.match(startSource, /new ResizeObserver\(scheduleFollowLatest\)/);
assert.match(startSource, /new ResizeObserver\(function \(\) \{\s*scheduleFollowLatest\(\)/);
assert.match(startSource, /ensureProcessDetailsReturnLatestControl\(timeline\)/);
assert.match(startSource, /markProcessDetailsReturnLatestPending\(timeline\)/);
assert.match(startSource, /scrollProcessDetailsToLatest\(String\(assistantMessageId \|\| ''\), false\)/);
assert.match(startSource, /timeline\.scrollTo\(\{ top: timeline\.scrollHeight, behavior: 'instant' \}\)/);
assert.match(startSource, /event\.deltaY < -1/);
assert.match(startSource, /state\.userScrollIntentUntil = Date\.now\(\) \+ 1200/);
assert.match(startSource, /event\.clientX >= rect\.right - PROCESS_DETAILS_FOLLOW_SCROLLBAR_GUTTER_PX/);
assert.match(startSource, /event\.key === 'ArrowUp'/);
assert.match(startSource, /cancelAnimationFrame\(state\.rafId\)/);
assert.match(startSource, /if \(scrolledUp && \(state\.detached \|\| Date\.now\(\) <= state\.userScrollIntentUntil\)\) \{[\s\S]*?detachForUserNavigation\(\)/);
assert.match(startSource, /state\.detached &&[\s\S]*?scrolledDown &&[\s\S]*?Date\.now\(\) <= state\.userScrollIntentUntil/);
assert.match(startSource, /topChange < -1 && hasUserScrollIntent && distance > PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX/);
assert.match(startSource, /state\.detached &&[\s\S]*?topChange > 1 &&[\s\S]*?hasUserScrollIntent/);
assert.match(monitor, /PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX = 2/);
assert.match(startSource, /distance <= PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX/);
assert.match(startSource, /state\.detached = false/);
@@ -291,7 +335,6 @@ test('首次实时输出与刷新恢复都保留独立迭代滚动并跟随最
assert.match(addSource, /startLiveProgressLatestFollow\(id\)/);
assert.match(liveSource, /stateKey: liveProgressLatestFollowKey\(id\)/);
assert.match(liveSource, /persistent: true/);
assert.match(liveSource, /target\.scrollTop = Math\.max\(0, target\.scrollHeight - target\.clientHeight\)/);
assert.match(monitor, /function finalizeProgressTask\(progressId, finalLabel\) \{[\s\S]{0,120}stopLiveProgressLatestFollow\(progressId\)/);
});
@@ -340,8 +383,8 @@ test('消息气泡内部流式增高时仅在跟随模式继续粘底', () => {
});
test('页面在任务补流脚本之前加载智能滚动控制器', () => {
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260815-1');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20261011-token-usage');
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=');
const monitorIndex = html.indexOf('/static/js/monitor.js?v=');
assert.notEqual(scrollIndex, -1);
assert.notEqual(monitorIndex, -1);
+87 -116
View File
@@ -27,8 +27,7 @@
let hasPendingNewBelow = false;
let listenersBound = false;
let lastScrollTop = 0;
let lastScrollHeight = 0;
let programmaticScroll = false;
let lastScrollRange = 0;
let detachLockUntil = 0;
/** 最近一次由用户发起的滚动意图;布局变化或脚本滚动不得据此恢复粘底。 */
let userScrollIntentUntil = 0;
@@ -214,19 +213,16 @@
const messagesEl = getChatMessagesEl();
const turn = collectConversationTurns()[index];
if (!messagesEl || !turn || !turn.user) return;
userScrollIntentUntil = 0;
setScrollDetached();
programmaticScroll = true;
messagesEl.scrollTo({
top: Math.max(0, turn.user.offsetTop - 20),
behavior: 'smooth'
behavior: 'instant'
});
rememberScrollPosition(messagesEl);
setActiveTurnMarker(index);
hideTurnPreview();
window.setTimeout(function () {
programmaticScroll = false;
lastScrollTop = messagesEl.scrollTop;
updateTurnRailActive();
}, 420);
updateReturnLatestButton();
}
function focusTurnMarker(index) {
@@ -344,10 +340,18 @@
}
}
function scrollRange(el) {
return Math.max(0, el.scrollHeight - el.clientHeight);
}
function rememberScrollPosition(el) {
lastScrollTop = el.scrollTop;
lastScrollRange = scrollRange(el);
}
function distanceFromBottom(el) {
if (!el) return 0;
const { scrollTop, scrollHeight, clientHeight } = el;
return scrollHeight - clientHeight - scrollTop;
return scrollRange(el) - el.scrollTop;
}
function isNearBottom(thresholdPx) {
@@ -383,6 +387,7 @@
function setScrollFollowing() {
scrollMode = 'following';
getChatMessagesEl()?.classList.add('is-following-latest');
detachLockUntil = 0;
userScrollIntentUntil = 0;
hasPendingNewBelow = false;
@@ -397,8 +402,12 @@
function setScrollDetached() {
scrollMode = 'detached';
getChatMessagesEl()?.classList.remove('is-following-latest');
detachLockUntil = Date.now() + DETACH_LOCK_MS;
cancelAnimationFrame(scrollFollowRaf);
scrollFollowRaf = 0;
scrollSettleGeneration += 1;
conversationRestoreGeneration += 1;
if (isStreamActive()) {
hasPendingNewBelow = true;
}
@@ -409,28 +418,11 @@
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;
}
});
const bottom = scrollRange(el);
if (bottom - el.scrollTop > CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX) {
el.scrollTo({ top: bottom, behavior: 'instant' });
rememberScrollPosition(el);
}
}
function updateTurnRailState() {
@@ -443,10 +435,7 @@
const messagesEl = getChatMessagesEl();
if (!button || !messagesEl) return;
const scrollable = messagesEl.scrollHeight > messagesEl.clientHeight + 2;
const atLatest = isNearBottom(CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX);
const readingDetachedHistory = scrollMode === 'detached' && !atLatest;
const farFromLatestFallback = !isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX);
const shouldShow = scrollable && (readingDetachedHistory || farFromLatestFallback);
const shouldShow = scrollable && !isNearBottom(CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX);
const streaming = shouldShow && isVisibleConversationTaskActive();
button.hidden = !shouldShow;
button.classList.toggle('is-streaming', streaming);
@@ -465,12 +454,9 @@
event.preventDefault();
event.stopPropagation();
}
forceScrollChatToBottom(true);
forceScrollChatToBottom();
const button = getReturnLatestButton();
if (button) {
button.hidden = true;
button.blur();
}
if (button) button.blur();
}
function canAutoScrollNow(wasPinnedBeforeDomUpdate) {
@@ -485,8 +471,11 @@
markPendingNewBelow();
return;
}
cancelAnimationFrame(scrollFollowRaf);
scrollFollowRaf = requestAnimationFrame(scrollChatToBottomInstant);
if (scrollFollowRaf) return;
scrollFollowRaf = requestAnimationFrame(function () {
scrollFollowRaf = 0;
scrollChatToBottomInstant();
});
}
/**
@@ -518,11 +507,10 @@
/**
* 刷新恢复长会话时,消息、详情和审批卡会跨多帧继续增高。
* 进入恢复流程时明确回到 following;用户随后若主动上滑,既有输入监听会立即
* 切换为 detached,并使后续校准帧停止,不会抢回阅读位置。
* 批次与审批恢复完成时沿用当前跟随状态。用户可以在此之前开始阅读历史。
*/
function settleConversationRestoreToBottom(frameCount) {
setScrollFollowing();
if (!captureScrollPinState()) return;
const requestedFrames = Number.isFinite(Number(frameCount))
? Math.max(1, Math.floor(Number(frameCount)))
: 30;
@@ -567,19 +555,16 @@
scheduleChatScrollToBottomIfFollowing(wasPinned);
}
function forceScrollChatToBottom(smooth) {
setScrollFollowing();
function forceScrollChatToBottom() {
cancelAnimationFrame(scrollFollowRaf);
if (smooth) {
scrollChatToBottomSmooth();
} else {
scrollChatToBottomInstant();
}
scrollFollowRaf = 0;
setScrollFollowing();
scrollChatToBottomInstant();
updateTurnRailState();
}
function onUserSendMessage() {
setScrollFollowing();
scrollChatToBottomInstant();
forceScrollChatToBottom();
}
function clearAllStreamingMarkers() {
@@ -648,16 +633,19 @@
const opt = (options && options.scroll) || 'follow';
if (opt === 'none') return;
if (opt === 'force') {
forceScrollChatToBottom(false);
forceScrollChatToBottom();
return;
}
scheduleChatScrollToBottomIfFollowing(captureScrollPinState());
}
/** 流式/用户未跟随时禁止 scrollIntoView 抢滚动 */
function scrollElementIntoViewIfFollowing(el, options) {
if (!el || !captureScrollPinState()) return;
el.scrollIntoView(options || { behavior: 'smooth', block: 'nearest' });
/** 手动展开详情时同步滚动,随后记录位置供滚动事件区分用户拖动。 */
function scrollElementIntoViewIfFollowing(el) {
const messagesEl = getChatMessagesEl();
if (!el || !messagesEl || !captureScrollPinState()) return;
el.scrollIntoView({ behavior: 'instant', block: 'nearest' });
rememberScrollPosition(messagesEl);
updateTurnRailState();
}
function onChatMessagesScroll() {
@@ -665,61 +653,44 @@
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 状态自然粘底,避免接近底部时突然跳动。
}
const range = scrollRange(el);
const topChange = st - lastScrollTop;
// 原生滚动条可能不派发指针事件。移动超出布局变化能够解释的距离时,
// 也视为用户滚动。脚本主动定位后会同步记录位置。
const hasUserScrollIntent = Date.now() <= userScrollIntentUntil ||
Math.abs(topChange) > Math.abs(range - lastScrollRange) + 1;
const scrolledUp = topChange < -1 && range - st > CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX;
const scrolledDown = topChange > 1;
lastScrollTop = st;
lastScrollHeight = sh;
lastScrollRange = range;
if (scrolledUp && hasUserScrollIntent) {
setScrollDetached();
} else if (scrolledDown && hasUserScrollIntent) {
resumeFollowingIfAtBottom(CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX, true);
}
updateTurnRailState();
}
function nestedTimelineCanScroll(target, direction) {
const timeline = target?.closest?.('.progress-timeline.expanded');
if (!timeline || scrollRange(timeline) <= CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX) return false;
return direction < 0
? timeline.scrollTop > CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX
: distanceFromBottom(timeline) > CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX;
}
function bindChatScrollListeners() {
if (listenersBound) return;
const el = getChatMessagesEl();
if (!el) return;
listenersBound = true;
lastScrollTop = el.scrollTop;
lastScrollHeight = el.scrollHeight;
el.classList.toggle('is-following-latest', scrollMode === 'following');
rememberScrollPosition(el);
el.addEventListener('wheel', function (e) {
// 只有内层到达边界后,滚轮才会传给外层对话区。
if (nestedTimelineCanScroll(e.target, e.deltaY)) return;
if (Math.abs(e.deltaY) > 1) {
userScrollIntentUntil = Date.now() + 1200;
}
@@ -738,22 +709,25 @@
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)) {
if (e.defaultPrevented || !scrollKeys.includes(e.key)) return;
const upwards = e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'Home' || (e.key === ' ' && e.shiftKey);
if (nestedTimelineCanScroll(e.target, upwards ? -1 : 1)) return;
userScrollIntentUntil = Date.now() + 1200;
if (upwards) {
setScrollDetached();
}
});
el.addEventListener('touchmove', function (e) {
if (e.touches && e.touches.length === 1) {
const y = e.touches[0].clientY;
const delta = (el._csTouchLastY ?? y) - y;
el._csTouchLastY = y;
if (nestedTimelineCanScroll(e.target, delta)) return;
userScrollIntentUntil = Date.now() + 1200;
el._csTouchLastY = el._csTouchLastY != null ? el._csTouchLastY : e.touches[0].clientY;
if (e.touches[0].clientY > el._csTouchLastY + 4) {
if (delta < -4) {
setScrollDetached();
}
el._csTouchLastY = e.touches[0].clientY;
}
}, { passive: true });
el.addEventListener('touchstart', function (e) {
@@ -823,10 +797,7 @@
function initChatScroll() {
bindChatScrollListeners();
const el = getChatMessagesEl();
if (el) {
lastScrollTop = el.scrollTop;
lastScrollHeight = el.scrollHeight;
}
if (el) rememberScrollPosition(el);
scheduleTurnRailRefresh(true);
updateTurnRailState();
}
+13 -15
View File
@@ -4205,10 +4205,7 @@ function renderProcessDetails(messageId, processDetails, options) {
return;
}
const prependAnchor = prependMode ? timeline.firstChild : null;
const prependScrollBox = prependMode ? document.getElementById('chat-messages') : null;
const prependScrollHeight = prependScrollBox ? prependScrollBox.scrollHeight : 0;
const prependScrollTop = prependScrollBox ? prependScrollBox.scrollTop : 0;
const prependAnchor = prependMode ? timeline.querySelector(':scope > .timeline-item') : null;
const prependedIds = [];
if (!appendMode && !prependMode) {
@@ -4506,10 +4503,13 @@ function renderProcessDetails(messageId, processDetails, options) {
const node = document.getElementById(id);
if (node) fragment.appendChild(node);
});
timeline.insertBefore(fragment, prependAnchor || timeline.firstChild);
if (prependScrollBox) {
const delta = prependScrollBox.scrollHeight - prependScrollHeight;
prependScrollBox.scrollTop = prependScrollTop + delta;
// 过程详情独立滚动。分帧渲染期间外层对话可能已被用户滚到别处。
const anchor = prependAnchor && prependAnchor.parentNode === timeline ? prependAnchor : null;
const anchorTop = anchor ? anchor.getBoundingClientRect().top : 0;
timeline.insertBefore(fragment, anchor || timeline.firstChild);
if (anchor && timeline.classList.contains('expanded')) {
const delta = anchor.getBoundingClientRect().top - anchorTop;
if (delta) timeline.scrollTo({ top: timeline.scrollTop + delta, behavior: 'instant' });
}
}
@@ -6623,11 +6623,6 @@ async function loadConversation(conversationId) {
if (offset < rest.length) {
requestAnimationFrame(renderNextBatch);
} else {
if (window.CyberStrikeChatScroll) {
window.CyberStrikeChatScroll.forceScrollToBottom(false);
} else {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
resolve();
}
};
@@ -6636,7 +6631,7 @@ async function loadConversation(conversationId) {
}
if (window.CyberStrikeChatScroll) {
window.CyberStrikeChatScroll.forceScrollToBottom(false);
window.CyberStrikeChatScroll.forceScrollToBottom();
} else {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
@@ -6653,6 +6648,9 @@ async function loadConversation(conversationId) {
if (currentConversationId === conversationId && typeof window.restoreHitlInlineForConversation === 'function') {
await window.restoreHitlInlineForConversation(conversationId);
}
if (seq !== loadConversationRequestSeq || currentConversationId !== conversationId) {
return;
}
if (
window.CyberStrikeChatScroll &&
typeof window.CyberStrikeChatScroll.settleConversationRestoreToBottom === 'function'
@@ -6662,7 +6660,7 @@ async function loadConversation(conversationId) {
} else {
renderChatWelcomeEmptyState();
if (window.CyberStrikeChatScroll) {
window.CyberStrikeChatScroll.forceScrollToBottom(false);
window.CyberStrikeChatScroll.forceScrollToBottom();
} else {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
+2 -2
View File
@@ -289,12 +289,12 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
assert.match(chat, /window\.clearChatHitlApprovalDock\(\)/);
assert.match(monitor, /if \(conversationId && conversationId !== currentId\) return false/);
assert.match(monitor, /function scrollProcessDetailsToLatest\(assistantMessageId, smooth = true\)/);
assert.match(monitor, /timeline\.scrollTop = targetTop/);
assert.match(monitor, /timeline\.scrollTo\(\{ top: targetTop, behavior: smooth \? 'smooth' : 'instant' \}\)/);
assert.match(chat, /let loadConversationAbortController = null/);
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,900}const conversationLoadController = new AbortController\(\)/);
assert.match(chat, /signal: conversationLoadController\.signal/);
assert.match(template, /monitor\.js\?v=20261011-token-usage/);
assert.match(template, /chat-scroll\.js\?v=20260815-1/);
assert.match(template, /chat-scroll\.js\?v=[A-Za-z0-9._-]+/);
assert.match(template, /chat\.js\?v=[A-Za-z0-9._-]+/);
assert.match(template, /style\.css\?v=[A-Za-z0-9._-]+/);
});
+58 -67
View File
@@ -1878,8 +1878,11 @@ function syncProcessDetailsLatestFollowAfterManualReturn(timeline) {
processDetailsLatestFollowStates.forEach(function (state) {
if (!state || state.timeline !== timeline) return;
state.detached = false;
timeline.classList.add('is-following-latest');
state.hasPendingNewBelow = false;
state.userScrollIntentUntil = 0;
state.lastScrollTop = timeline.scrollTop;
state.lastScrollRange = Math.max(0, timeline.scrollHeight - timeline.clientHeight);
if (!state.stopped && typeof state.scheduleFollowLatest === 'function') {
state.scheduleFollowLatest();
}
@@ -1936,20 +1939,10 @@ function ensureProcessDetailsReturnLatestControl(timeline) {
event.preventDefault();
event.stopPropagation();
}
const targetTop = Math.max(0, timeline.scrollHeight - timeline.clientHeight);
if (typeof timeline.scrollTo === 'function') {
timeline.scrollTo({ top: targetTop, behavior: 'smooth' });
} else {
timeline.scrollTop = targetTop;
}
timeline.scrollTo({ top: timeline.scrollHeight, behavior: 'instant' });
state.hasPendingNewBelow = false;
syncProcessDetailsLatestFollowAfterManualReturn(timeline);
updateProcessDetailsReturnLatestControl(timeline);
setTimeout(function () {
if (timeline.isConnected) {
updateProcessDetailsReturnLatestControl(timeline);
}
}, 360);
button.blur();
};
const onScroll = function () {
@@ -2116,11 +2109,7 @@ function scrollProcessDetailsToLatest(assistantMessageId, smooth = true) {
const timeline = container && container.querySelector('.progress-timeline');
if (!timeline) return false;
const targetTop = Math.max(0, timeline.scrollHeight - timeline.clientHeight);
if (smooth && typeof timeline.scrollTo === 'function') {
timeline.scrollTo({ top: targetTop, behavior: 'smooth' });
} else {
timeline.scrollTop = targetTop;
}
timeline.scrollTo({ top: targetTop, behavior: smooth ? 'smooth' : 'instant' });
const state = processDetailsReturnLatestControls.get(timeline);
if (state) {
state.hasPendingNewBelow = false;
@@ -2136,6 +2125,7 @@ function stopProcessDetailsLatestFollow(assistantMessageId) {
const state = processDetailsLatestFollowStates.get(key);
if (!state) return false;
state.stopped = true;
state.timeline.classList.remove('has-latest-follow', 'is-following-latest');
if (state.rafId) cancelAnimationFrame(state.rafId);
if (state.timeoutId) clearTimeout(state.timeoutId);
if (state.observer) state.observer.disconnect();
@@ -2167,19 +2157,26 @@ function startProcessDetailsLatestFollow(assistantMessageId, options) {
const timeline = explicitTimeline || (container && container.querySelector('.progress-timeline'));
if (!container || !timeline) return false;
const persistent = !!opts.persistent;
const previousState = processDetailsLatestFollowStates.get(key);
// 最新页恢复接入补流时保留原状态,避免把正在阅读历史的用户拉到底部。
if (previousState && previousState.timeline === timeline && persistent) {
clearTimeout(previousState.timeoutId);
previousState.timeoutId = 0;
return true;
}
stopProcessDetailsLatestFollow(key);
ensureProcessDetailsReturnLatestControl(timeline);
const persistent = !!opts.persistent;
const durationMs = Number.isFinite(Number(opts.durationMs))
? Math.max(250, Number(opts.durationMs))
: PROCESS_DETAILS_RESTORE_FOLLOW_MS;
const state = {
stopped: false,
detached: false,
persistent: persistent,
timeline: timeline,
hasPendingNewBelow: false,
lastScrollTop: timeline.scrollTop,
lastScrollRange: Math.max(0, timeline.scrollHeight - timeline.clientHeight),
userScrollIntentUntil: 0,
touchLastY: null,
rafId: 0,
@@ -2189,9 +2186,11 @@ function startProcessDetailsLatestFollow(assistantMessageId, options) {
cleanup: []
};
processDetailsLatestFollowStates.set(key, state);
timeline.classList.add('has-latest-follow', 'is-following-latest');
const detachForUserNavigation = function () {
state.detached = true;
timeline.classList.remove('is-following-latest');
if (state.rafId) {
cancelAnimationFrame(state.rafId);
state.rafId = 0;
@@ -2203,7 +2202,12 @@ function startProcessDetailsLatestFollow(assistantMessageId, options) {
if (Math.abs(event.deltaY) > 1) {
state.userScrollIntentUntil = Date.now() + 1200;
}
if (event.deltaY < -1) detachForUserNavigation();
if (event.deltaY < -1 && timeline.scrollTop > PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX) {
detachForUserNavigation();
} else if (event.deltaY > 1 && state.detached &&
processDetailsDistanceFromLatest(timeline) <= PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX) {
syncProcessDetailsLatestFollowAfterManualReturn(timeline);
}
};
const onPointerDown = function (event) {
if (!event) return;
@@ -2213,11 +2217,12 @@ function startProcessDetailsLatestFollow(assistantMessageId, options) {
}
};
const onKeyDown = function (event) {
if (!event) return;
if (!event || event.defaultPrevented) return;
if (['ArrowUp', 'PageUp', 'Home', 'ArrowDown', 'PageDown', 'End', ' '].includes(event.key)) {
state.userScrollIntentUntil = Date.now() + 1200;
}
if (event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home') {
if ((event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home' ||
(event.key === ' ' && event.shiftKey)) && timeline.scrollTop > PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX) {
detachForUserNavigation();
}
};
@@ -2230,7 +2235,8 @@ function startProcessDetailsLatestFollow(assistantMessageId, options) {
if (!event || !event.touches || event.touches.length !== 1) return;
const nextY = event.touches[0].clientY;
state.userScrollIntentUntil = Date.now() + 1200;
if (state.touchLastY != null && nextY > state.touchLastY + 4) {
if (state.touchLastY != null && nextY > state.touchLastY + 4 &&
timeline.scrollTop > PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX) {
detachForUserNavigation();
}
state.touchLastY = nextY;
@@ -2241,30 +2247,31 @@ function startProcessDetailsLatestFollow(assistantMessageId, options) {
const onScroll = function () {
if (state.stopped) return;
const currentTop = timeline.scrollTop;
const scrolledUp = currentTop < state.lastScrollTop - 1;
const scrolledDown = currentTop > state.lastScrollTop + 1;
const distance = Math.max(0, timeline.scrollHeight - timeline.clientHeight - timeline.scrollTop);
const range = Math.max(0, timeline.scrollHeight - timeline.clientHeight);
const topChange = currentTop - state.lastScrollTop;
const distance = Math.max(0, range - currentTop);
const hasUserScrollIntent = Date.now() <= state.userScrollIntentUntil ||
Math.abs(topChange) > Math.abs(range - state.lastScrollRange) + 1;
state.lastScrollTop = currentTop;
state.lastScrollRange = range;
// 第一次小幅上滑时仍可能处在“距底部 32px”阈值内,不能在同一个 scroll
// 事件里刚脱离又立即恢复,否则持续输出会把视口反复拉回底部。
// 刷新后重建历史迭代会引起无用户输入的滚动锚定变化;这种布局滚动
// 不能停止内层最新内容跟随,只有明确用户意图或已分离状态才保持分离。
if (scrolledUp && (state.detached || Date.now() <= state.userScrollIntentUntil)) {
// 用滚动范围的变化区分原生滚动条拖动和详情重绘引起的滚动锚定。
if (topChange < -1 && hasUserScrollIntent && distance > PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX) {
detachForUserNavigation();
} else if (
state.detached &&
scrolledDown &&
Date.now() <= state.userScrollIntentUntil &&
topChange > 1 &&
hasUserScrollIntent &&
distance <= PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX
) {
state.detached = false;
timeline.classList.add('is-following-latest');
state.hasPendingNewBelow = false;
scheduleFollowLatest();
}
if (distance <= PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX) {
state.hasPendingNewBelow = false;
}
state.lastScrollTop = currentTop;
updateProcessDetailsReturnLatestControl(timeline);
};
timeline.addEventListener('wheel', onWheel, { passive: true });
@@ -2289,19 +2296,13 @@ function startProcessDetailsLatestFollow(assistantMessageId, options) {
return;
}
if (state.detached) return;
if (typeof opts.scrollToLatest === 'function') {
opts.scrollToLatest(timeline);
} else {
scrollProcessDetailsToLatest(String(assistantMessageId || ''), false);
if (processDetailsDistanceFromLatest(timeline) > PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX) {
timeline.scrollTo({ top: timeline.scrollHeight, behavior: 'instant' });
}
state.hasPendingNewBelow = false;
state.lastScrollTop = timeline.scrollTop;
state.lastScrollRange = Math.max(0, timeline.scrollHeight - timeline.clientHeight);
updateProcessDetailsReturnLatestControl(timeline);
// 内层迭代区与外层对话区分别粘底;用户上滑内层后,本状态会暂停两者的自动跟随。
if (window.CyberStrikeChatScroll &&
typeof window.CyberStrikeChatScroll.scrollIfPinned === 'function') {
window.CyberStrikeChatScroll.scrollIfPinned(true);
}
};
const scheduleFollowLatest = function () {
if (state.stopped || state.rafId) return;
@@ -2317,7 +2318,13 @@ function startProcessDetailsLatestFollow(assistantMessageId, options) {
state.observer = new MutationObserver(scheduleFollowLatest);
state.observer.observe(timeline, { childList: true, subtree: true, characterData: true });
if (typeof ResizeObserver === 'function') {
state.resizeObserver = new ResizeObserver(scheduleFollowLatest);
state.resizeObserver = new ResizeObserver(function () {
scheduleFollowLatest();
// 展开、收起和尺寸过渡会改变外层对话高度,且可能没有新增 DOM。
if (timeline.closest('#chat-messages')) {
window.CyberStrikeChatScroll?.scrollIfPinned(true);
}
});
state.resizeObserver.observe(timeline);
}
scheduleFollowLatest();
@@ -2341,10 +2348,7 @@ function startLiveProgressLatestFollow(progressId) {
return startProcessDetailsLatestFollow(id, {
stateKey: liveProgressLatestFollowKey(id),
timeline: timeline,
persistent: true,
scrollToLatest: function (target) {
target.scrollTop = Math.max(0, target.scrollHeight - target.clientHeight);
}
persistent: true
});
}
@@ -2461,17 +2465,13 @@ async function loadProcessDetailsPaginated(assistantMessageId, backendMessageId,
durationMs: PROCESS_DETAILS_RESTORE_FOLLOW_MS
});
requestAnimationFrame(function () {
scrollProcessDetailsToLatest(assistantMessageId, false);
if (detailsContainer) {
delete detailsContainer.dataset.autoLoadSuspended;
}
});
} else if (opts.initialStart) {
requestAnimationFrame(function () {
const container = document.getElementById('process-details-' + assistantMessageId);
const timeline = container && container.querySelector('.progress-timeline');
if (timeline) timeline.scrollTop = 0;
});
const timeline = detailsContainer && detailsContainer.querySelector('.progress-timeline');
if (timeline) timeline.scrollTo({ top: 0, behavior: 'instant' });
}
}
@@ -2625,13 +2625,11 @@ function toggleProcessDetails(progressId, assistantMessageId) {
// 滚动到展开的详情位置(流式且用户上滑阅读时不抢主列表滚动)
if (timeline && timeline.classList.contains('expanded')) {
setTimeout(() => {
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.scrollIntoViewIfFollowing === 'function') {
window.CyberStrikeChatScroll.scrollIntoViewIfFollowing(detailsContainer, { behavior: 'smooth', block: 'nearest' });
} else if (typeof window.captureScrollPinState === 'function' ? window.captureScrollPinState() : true) {
detailsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 100);
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.scrollIntoViewIfFollowing === 'function') {
window.CyberStrikeChatScroll.scrollIntoViewIfFollowing(detailsContainer);
} else if (typeof window.captureScrollPinState === 'function' ? window.captureScrollPinState() : true) {
detailsContainer.scrollIntoView({ behavior: 'instant', block: 'nearest' });
}
}
}
@@ -5330,13 +5328,6 @@ function expandProcessDetailsTimeline(assistantMessageId) {
if (typeof window.syncAssistantTurnSummary === 'function') {
window.syncAssistantTurnSummary(document.getElementById(assistantMessageId));
}
setTimeout(function () {
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.scrollIntoViewIfFollowing === 'function') {
window.CyberStrikeChatScroll.scrollIntoViewIfFollowing(detailsContainer, { behavior: 'smooth', block: 'nearest' });
} else if (typeof window.captureScrollPinState === 'function' ? window.captureScrollPinState() : true) {
detailsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 100);
}
function findLastAssistantMessageElInChat() {
+4 -4
View File
@@ -39,7 +39,7 @@
}
})();
</script>
<link rel="stylesheet" href="/static/css/style.css?v=20261027-pagination-custom-select">
<link rel="stylesheet" href="/static/css/style.css?v=20261027-pagination-custom-select-20261113-chat-scroll">
<link rel="stylesheet" href="/static/css/vulnerability-download.css?v=20261030-2">
<link rel="stylesheet" href="/static/css/tool-guard.css?v=20260907-7">
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
@@ -7026,10 +7026,10 @@
<script src="/static/js/assets.js?v=20260717-7"></script>
<script src="/static/js/agents.js"></script>
<script src="/static/js/dashboard.js?v=20261011-token-settings-cache-only"></script>
<script src="/static/js/chat-scroll.js?v=20260815-1"></script>
<script src="/static/js/monitor.js?v=20261011-token-usage-pagination-20261027"></script>
<script src="/static/js/chat-scroll.js?v=20260815-1-20261113-chat-scroll"></script>
<script src="/static/js/monitor.js?v=20261011-token-usage-pagination-20261027-20261113-chat-scroll"></script>
<script src="/static/js/attack-chain-context.js?v=20261012-summary-2"></script>
<script src="/static/js/chat.js?v=20261016-context-memory-token-usage-pagination-20261027"></script>
<script src="/static/js/chat.js?v=20261016-context-memory-token-usage-pagination-20261027-20261113-chat-scroll"></script>
<script src="/static/js/chat-plan-progress.js?v=20260815-1"></script>
<script src="/static/js/hitl.js?v=20260819-1-pagination-20261027"></script>
<script src="/static/js/tool-guard.js?v=20261010-conflict"></script>