From 8819d25e37f599b14c01634a606dfc49a4033535 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:54:04 +0800 Subject: [PATCH] fix(chat): stabilize scrolling in long streaming conversations --- scripts/chat-scroll-browser.test.cjs | 147 +++++++++++++++ web/static/css/style.css | 11 ++ web/static/js/chat-scroll-refresh.test.cjs | 77 ++++++-- web/static/js/chat-scroll.js | 203 +++++++++------------ web/static/js/chat.js | 28 ++- web/static/js/hitl-approval-ui.test.cjs | 4 +- web/static/js/monitor.js | 125 ++++++------- web/templates/index.html | 8 +- 8 files changed, 382 insertions(+), 221 deletions(-) create mode 100644 scripts/chat-scroll-browser.test.cjs 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 a0f3cc8d..300e5e28 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 24dfa94b..b51ea89b 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 5cfb5aee..73990038 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -4201,10 +4201,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) { @@ -4502,10 +4499,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' }); } } @@ -6619,11 +6619,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(); } }; @@ -6632,7 +6627,7 @@ async function loadConversation(conversationId) { } if (window.CyberStrikeChatScroll) { - window.CyberStrikeChatScroll.forceScrollToBottom(false); + window.CyberStrikeChatScroll.forceScrollToBottom(); } else { messagesDiv.scrollTop = messagesDiv.scrollHeight; } @@ -6649,6 +6644,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' @@ -6658,7 +6656,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 58d4e226..213c4ce3 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 1571baf6..c109f10d 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -39,7 +39,7 @@ } })(); - + @@ -7051,10 +7051,10 @@ - - + + - +