mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-11-14 00:07:10 +01:00
Merge pull request #396 from Sycun/codex/fix-long-chat-scroll
fix(chat): stabilize scrolling in long streaming conversations
This commit is contained in:
8 files changed
+382
-221
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user