agent-Specialization/static/src/app/methods/ui/scroll.ts

458 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @ts-nocheck
import { debugLog } from '../common';
import { usePolicyStore } from '../../../stores/policy';
import { useModelStore } from '../../../stores/model';
import { usePersonalizationStore } from '../../../stores/personalization';
import { useTutorialStore } from '../../../stores/tutorial';
import { renderMarkdown as renderMarkdownHelper } from '../../../composables/useMarkdownRenderer';
import { scrollToBottom as scrollToBottomHelper, conditionalScrollToBottom as conditionalScrollToBottomHelper, scrollThinkingToBottom as scrollThinkingToBottomHelper } from '../../../composables/useScrollControl';
import { startResize as startPanelResize, handleResize as handlePanelResize, stopResize as stopPanelResize } from '../../../composables/usePanelResize';
import {
SUB_AGENT_DONE_PREFIX_RE,
BG_RUN_COMMAND_DONE_PREFIX_RE,
userMDebug,
UI_BOUNCE_TRACE_MAX,
uiBounceTraceLastTsByKey,
isUiBounceTraceEnabled,
uiBounceTrace,
isConnectionDiagEnabled,
pushConnectionDiagRecord,
connectionDiag,
parseSubAgentDoneLabel,
parseBackgroundRunCommandDoneLabel,
parseSystemNoticeLabel,
} from './shared';
export const scrollMethods = {
clearLocalTaskUiState(reason = 'safe-navigation') {
// 只清理当前浏览器视图里的运行态/工具态,不取消后端任务。
// 用于切换对话/工作区后,避免旧任务残留让新视图的发送按钮显示为“停止”。
try {
if (typeof this.stopWaitingTaskProbe === 'function') {
this.stopWaitingTaskProbe();
}
} catch (_) {}
this.streamingMessage = false;
this.taskInProgress = false;
this.stopRequested = false;
this.waitingForSubAgent = false;
this.waitingForBackgroundCommand = false;
this.dropToolEvents = false;
this.runtimeQueuedMessages = [];
this.runtimeGuidanceFallbackQueue = [];
this.runtimeQueueAutoSendInProgress = false;
this.runtimeQueueSyncLockKey = '';
this.runtimeQueueSyncLockUntil = 0;
this.goalModeArmed = false;
this.goalRunning = false;
this.goalProgress = null;
this.goalDialogOpen = false;
if (typeof this.clearRuntimeQueueSuppressionState === 'function') {
this.clearRuntimeQueueSuppressionState();
} else {
this.runtimeQueueSuppressedMessageIds = new Set();
this.runtimeGuidanceSuppressedTextCounts = {};
}
if (typeof this.clearPendingTools === 'function') {
this.clearPendingTools(reason);
} else {
this.preparingTools?.clear?.();
this.activeTools?.clear?.();
this.toolActionIndex?.clear?.();
this.toolStacks?.clear?.();
}
if (typeof this.chatClearThinkingLocks === 'function') {
this.chatClearThinkingLocks();
}
this.$forceUpdate?.();
},
ensureScrollListener() {
if (this._scrollListenerReady) {
return;
}
const area = this.getMessagesAreaElement();
if (!area) {
return;
}
this.initScrollListener();
this._scrollListenerReady = true;
},
handleStickStateChange(payload) {
const escaped = !!payload?.escapedFromLock;
const isAtBottom = !!payload?.isAtBottom;
const isNearBottom = !!payload?.isNearBottom;
const nearBottom = isAtBottom || isNearBottom;
const userEscaped = !!this._escapedByUserScroll;
uiBounceTrace(
'stick-state-change',
{
isAtBottom,
isNearBottom,
escapedFromLock: escaped,
userEscaped,
autoScrollEnabled: this.autoScrollEnabled,
userScrolling: this.userScrolling
},
'stick-state-change',
180
);
this.stickIsAtBottom = isAtBottom;
this.stickIsNearBottom = isNearBottom;
// 用户主动脱离锁定:只要没回到底部/近底部,就保持“手动滚动中”状态
if (userEscaped) {
if (nearBottom) {
this._escapedByUserScroll = false;
this.chatSetScrollState({ userScrolling: false });
} else {
this.chatSetScrollState({ userScrolling: true });
}
return;
}
// 非用户触发的 escaped例如高度突增导致的短暂锚点漂移不应解除锁定
this.chatSetScrollState({ userScrolling: false });
if (!escaped) {
return;
}
const active = typeof this.isOutputActive === 'function' ? this.isOutputActive() : true;
if (!this.autoScrollEnabled || !active) {
return;
}
const chatArea = this.getChatAreaController();
if (chatArea && typeof chatArea.conditionalStickToBottom === 'function') {
chatArea.conditionalStickToBottom({ force: false });
return;
}
conditionalScrollToBottomHelper(this);
},
handleUserScrollIntent(payload) {
const ts = Number(payload?.ts || Date.now());
const chatArea = this.getChatAreaController();
if (chatArea && typeof chatArea.stopStickScroll === 'function') {
chatArea.stopStickScroll();
}
// 用户手动滚动后,短时间内禁止任何自动追底,规避 escapedFromLock 状态抖动竞态
this._manualScrollSuppressUntil = Math.max(this._manualScrollSuppressUntil || 0, ts + 1600);
this._escapedByUserScroll = true;
this.chatSetScrollState({ userScrolling: true });
uiBounceTrace(
'ui.user-scroll-intent',
{
ts,
delta: Number(payload?.delta || 0),
top: Number(payload?.top || 0),
suppressUntil: this._manualScrollSuppressUntil
},
'ui.user-scroll-intent',
80
);
},
scrollHistoryToBottomInstant() {
this._escapedByUserScroll = false;
const chatArea = this.getChatAreaController();
if (chatArea && typeof chatArea.stopStickScroll === 'function') {
chatArea.stopStickScroll();
}
const area = this.getMessagesAreaElement();
if (!area) {
return;
}
const jump = () => {
area.scrollTop = area.scrollHeight;
};
jump();
requestAnimationFrame(() => {
jump();
requestAnimationFrame(jump);
});
this.chatSetScrollState({ userScrolling: false });
},
async settleHistoryRenderAndScroll() {
this._escapedByUserScroll = false;
const chatArea = this.getChatAreaController();
if (chatArea && typeof chatArea.stopStickScroll === 'function') {
chatArea.stopStickScroll();
}
const area = this.getMessagesAreaElement();
if (!area) {
return;
}
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const sleep = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
const jump = () => {
area.scrollTop = area.scrollHeight;
};
let lastHeight = -1;
let stableFrames = 0;
const startedAt = Date.now();
while (Date.now() - startedAt < 900 && stableFrames < 4) {
await nextFrame();
jump();
const height = area.scrollHeight;
if (Math.abs(height - lastHeight) <= 1) {
stableFrames += 1;
} else {
stableFrames = 0;
lastHeight = height;
}
}
// 给 show_html / 表格滚动壳等异步同步 DOM 一次短暂稳定窗口;仍在 historyLoading 隐藏期内完成。
await sleep(80);
jump();
await nextFrame();
jump();
this.chatSetScrollState({ userScrolling: false });
},
scrollToBottom() {
uiBounceTrace(
'ui.scrollToBottom:called',
{
autoScrollEnabled: this.autoScrollEnabled,
userScrolling: this.userScrolling
},
'ui.scrollToBottom:called',
80
);
this._escapedByUserScroll = false;
const chatArea = this.getChatAreaController();
if (chatArea && typeof chatArea.scrollToBottom === 'function') {
chatArea.scrollToBottom({
behavior: 'auto',
force: this.autoScrollEnabled
});
this.chatSetScrollState({ userScrolling: false });
return;
}
scrollToBottomHelper(this);
},
conditionalScrollToBottom() {
const active = typeof this.isOutputActive === 'function' ? this.isOutputActive() : true;
const chatArea = this.getChatAreaController();
const stickState =
chatArea && typeof chatArea.getStickState === 'function' ? chatArea.getStickState() : null;
uiBounceTrace(
'ui.conditionalScrollToBottom:called',
{
active,
autoScrollEnabled: this.autoScrollEnabled,
userScrolling: this.userScrolling,
stickIsNearBottom: this.stickIsNearBottom,
escapedFromLock: !!stickState?.escapedFromLock
},
'ui.conditionalScrollToBottom:called',
120
);
if (!active) {
return;
}
const now = Date.now();
if (now <= (this._manualScrollSuppressUntil || 0)) {
uiBounceTrace(
'ui.conditionalScrollToBottom:skip-manual-cooldown',
{
now,
suppressUntil: this._manualScrollSuppressUntil
},
'ui.conditionalScrollToBottom:skip-manual-cooldown',
120
);
return;
}
// 仅在“确认是用户主动脱离锁定”时才阻断自动追底;
// 对于内容高度突变导致的 escapedFromLock不应长期卡住锁定。
if (stickState?.escapedFromLock && this._escapedByUserScroll) {
uiBounceTrace(
'ui.conditionalScrollToBottom:skip-escaped-lock',
{
escapedFromLock: !!stickState?.escapedFromLock,
escapedByUser: !!this._escapedByUserScroll,
isNearBottom: !!stickState?.isNearBottom,
isAtBottom: !!stickState?.isAtBottom
},
'ui.conditionalScrollToBottom:skip-escaped-lock',
120
);
return;
}
// 只要用户处于手动滚动状态,就停止自动追底,避免“被弹回”
// stickIsNearBottom 在 show_html 动态重排时可能短暂失真,不可作为阻断前置条件)
if (this.userScrolling) {
uiBounceTrace(
'ui.conditionalScrollToBottom:skip-user-scrolling',
{
userScrolling: this.userScrolling,
stickIsNearBottom: this.stickIsNearBottom
},
'ui.conditionalScrollToBottom:skip-user-scrolling',
120
);
return;
}
if (chatArea && typeof chatArea.conditionalStickToBottom === 'function') {
chatArea.conditionalStickToBottom({ force: false });
return;
}
conditionalScrollToBottomHelper(this);
},
toggleScrollLock() {
uiBounceTrace(
'ui.scrollToBottomButton:clicked',
{
autoScrollEnabled: this.autoScrollEnabled,
userScrolling: this.userScrolling,
stickNearBottom: this.stickIsNearBottom
},
'ui.scrollToBottomButton:clicked',
0
);
const chatArea = this.getChatAreaController();
if (chatArea && typeof chatArea.scrollToBottom === 'function') {
chatArea.scrollToBottom({ behavior: 'smooth', force: true });
} else {
scrollToBottomHelper(this, {
ignoreUserScrolling: true,
resetUserScrolling: true,
behavior: 'smooth',
force: true
});
}
this._escapedByUserScroll = false;
this.chatSetScrollState({ autoScrollEnabled: true, userScrolling: false });
return true;
},
scrollThinkingToBottom(blockId) {
scrollThinkingToBottomHelper(this, blockId);
},
initScrollListener() {
const chatArea = this.getChatAreaController();
if (
chatArea &&
typeof chatArea.isUsingStickToBottom === 'function' &&
chatArea.isUsingStickToBottom()
) {
this._scrollListenerReady = true;
return;
}
const messagesArea = this.getMessagesAreaElement();
if (!messagesArea) {
console.warn('消息区域未找到');
return;
}
this._scrollListenerReady = true;
let scrollDebugCount = 0;
const SCROLL_DEBUG_MAX = 1200;
let scrollDebugLastTs = 0;
const isScrollDebugEnabled = () => {
if (typeof window === 'undefined') return false;
const until = Number((window as any).__SHOW_TAG_DRAWING_UNTIL__ || 0);
return Date.now() <= until;
};
const scrollDebug = (event, payload = {}, throttleMs = 80) => {
if (!isScrollDebugEnabled()) {
return;
}
if (scrollDebugCount >= SCROLL_DEBUG_MAX) {
return;
}
const now = Date.now();
if (throttleMs > 0 && now - scrollDebugLastTs < throttleMs) {
return;
}
scrollDebugLastTs = now;
scrollDebugCount += 1;
if (scrollDebugCount === SCROLL_DEBUG_MAX) {
console.warn('[SCROLL_DEBUG]', 'listener:log-limit-reached', { max: SCROLL_DEBUG_MAX });
return;
}
console.log('[SCROLL_DEBUG]', event, payload);
};
scrollDebug(
'listener:setup',
{
autoScrollEnabled: this.autoScrollEnabled,
userScrolling: this.userScrolling
},
0
);
let isProgrammaticScroll = false;
const bottomThreshold = 12;
this._setScrollingFlag = (flag) => {
isProgrammaticScroll = !!flag;
scrollDebug('listener:programmatic-flag', { flag: !!flag }, 0);
};
messagesArea.addEventListener('scroll', () => {
if (isProgrammaticScroll) {
scrollDebug(
'listener:scroll:ignore-programmatic',
{
top: messagesArea.scrollTop,
height: messagesArea.scrollHeight,
client: messagesArea.clientHeight
},
120
);
return;
}
const scrollTop = messagesArea.scrollTop;
const scrollHeight = messagesArea.scrollHeight;
const clientHeight = messagesArea.clientHeight;
const isAtBottom = scrollHeight - scrollTop - clientHeight < bottomThreshold;
const nearBottomThreshold = 48;
const isNearBottom = scrollHeight - scrollTop - clientHeight < nearBottomThreshold;
const active = typeof this.isOutputActive === 'function' ? this.isOutputActive() : true;
if (this.autoScrollEnabled && active) {
// 锁定模式下不让 userScrolling 状态来回抖动
this.chatSetScrollState({ userScrolling: false });
scrollDebug('listener:scroll:locked-keep-bottom-state', {
top: scrollTop,
height: scrollHeight,
client: clientHeight,
remain: scrollHeight - scrollTop - clientHeight,
isAtBottom,
isNearBottom
});
return;
}
// 仅维护状态,不在 scroll 事件中强制写 scrollTop避免上下抖动
this.chatSetScrollState({ userScrolling: !(isAtBottom || isNearBottom) });
scrollDebug('listener:scroll:update-state', {
top: scrollTop,
height: scrollHeight,
client: clientHeight,
remain: scrollHeight - scrollTop - clientHeight,
isAtBottom,
isNearBottom,
autoScrollEnabled: this.autoScrollEnabled,
userScrollingBefore: this.userScrolling,
userScrollingNext: !(isAtBottom || isNearBottom)
});
});
},
handleThinkingScroll(blockId, event) {
if (!blockId || !event || !event.target) {
return;
}
const el = event.target;
const threshold = 12;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
this.chatSetThinkingLock(blockId, atBottom);
},
toggleBlock(blockId) {
if (!blockId) {
return;
}
if (this.expandedBlocks && this.expandedBlocks.has(blockId)) {
this.chatCollapseBlock(blockId);
} else {
this.chatExpandBlock(blockId);
}
}
};