diff --git a/scripts/chat-scroll-browser.test.cjs b/scripts/chat-scroll-browser.test.cjs
new file mode 100644
index 00000000..7750295a
--- /dev/null
+++ b/scripts/chat-scroll-browser.test.cjs
@@ -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: `
+ `
+ })
+ : 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 = 'Synthetic history
';
+ chat.append(row);
+ }
+ if (restored) {
+ chat.insertAdjacentHTML('beforeend', `
+ `);
+ 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();
+ }
+});
diff --git a/web/static/css/style.css b/web/static/css/style.css
index 7346ff76..01efa335 100644
--- a/web/static/css/style.css
+++ b/web/static/css/style.css
@@ -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;
diff --git a/web/static/js/chat-scroll-refresh.test.cjs b/web/static/js/chat-scroll-refresh.test.cjs
index 6c9417fb..75c5f778 100644
--- a/web/static/js/chat-scroll-refresh.test.cjs
+++ b/web/static/js/chat-scroll-refresh.test.cjs
@@ -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);
diff --git a/web/static/js/chat-scroll.js b/web/static/js/chat-scroll.js
index 285f4404..9696c6be 100644
--- a/web/static/js/chat-scroll.js
+++ b/web/static/js/chat-scroll.js
@@ -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();
}
diff --git a/web/static/js/chat.js b/web/static/js/chat.js
index 388d7a51..4677bbad 100644
--- a/web/static/js/chat.js
+++ b/web/static/js/chat.js
@@ -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;
}
diff --git a/web/static/js/hitl-approval-ui.test.cjs b/web/static/js/hitl-approval-ui.test.cjs
index cf15377e..cf817b20 100644
--- a/web/static/js/hitl-approval-ui.test.cjs
+++ b/web/static/js/hitl-approval-ui.test.cjs
@@ -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._-]+/);
});
diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js
index 602bd3c6..19bc1e91 100644
--- a/web/static/js/monitor.js
+++ b/web/static/js/monitor.js
@@ -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() {
diff --git a/web/templates/index.html b/web/templates/index.html
index 17303365..79bac336 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -39,7 +39,7 @@
}
})();
-
+
@@ -7026,10 +7026,10 @@
-
-
+
+
-
+