mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-14 15:10:20 +02:00
优化项目对话、刷新续流、实时滚动与工具状态恢复 (#245)
* feat(chat): add project-based conversation sidebar * feat(chat): refine Codex-style conversation UI * feat(chat): add Codex-style conversation workflow * feat(ui): 优化对话框与项目侧边栏交互 * fix(chat): 修复暗色输入框圆角填色 * fix(chat): 恢复输入区分层错位布局 * fix(hitl): isolate reviewer state per conversation * feat(hitl): 增加双入口审批与倒计时进度 * fix(hitl): 汇总项目审批并隔离对话状态 * fix(ui): 修复审批状态与无项目新任务 * fix(ui): 优化审批状态与对话切换性能 * fix(ui): 修复中断任务审批仍计时 * fix(ui): 修复多对话并发切换卡顿 * fix(hitl): 主动同步审批并关闭中断状态 * fix(ui): 固定项目审批汇总为绿色 * feat(ui): 同步系统模型与推理强度 * feat(ui): 完善项目侧栏预览与新建入口 * fix(ui): 防止无项目文件夹误展开 * fix(ui): 防止长历史对话滚动误触审批 * fix(ui): 修复对话操作并补充项目置顶 * fix(ui): 移除对话分组并调整项目置顶排序 * feat(ui): 优化迭代导航与审批交互 * fix(hitl): 将 write_file 加入内置免审批工具 * fix(chat): 支持回车发送与 Shift 回车换行 * fix(chat): 优化对话刷新与 Codex 风格交互 * fix(ui): 显示对话具体更新时间 * fix(ui): 优化对话刷新与项目加载 * fix(ui): 修复 Agent 审查文字裁切 * fix(chat): 修复刷新续流与多标签页同步 * fix(chat): 修复滚动跟随与中断任务终态 * fix(ui): 修复流式滚动跳动与工具状态恢复 * fix(ui): 修复刷新后流式输出停止粘底
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const scroll = fs.readFileSync('web/static/js/chat-scroll.js', 'utf8');
|
||||
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
const router = fs.readFileSync('web/static/js/router.js', 'utf8');
|
||||
const auth = fs.readFileSync('web/static/js/auth.js', 'utf8');
|
||||
const html = fs.readFileSync('web/templates/index.html', 'utf8');
|
||||
|
||||
function functionSource(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start);
|
||||
assert.notEqual(start, -1, `${name} should exist`);
|
||||
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function createScrollRuntime() {
|
||||
const listeners = new Map();
|
||||
const buttonListeners = new Map();
|
||||
const classList = { add() {}, remove() {}, toggle() {}, contains() { return false; } };
|
||||
const chatEl = {
|
||||
scrollTop: 500,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 500,
|
||||
children: [],
|
||||
classList,
|
||||
addEventListener(type, handler) { listeners.set(type, handler); },
|
||||
scrollTo(options) { this.scrollTop = Number(options && options.top) || 0; },
|
||||
getBoundingClientRect() { return { right: 1000 }; },
|
||||
};
|
||||
const returnLatest = {
|
||||
hidden: true,
|
||||
classList,
|
||||
addEventListener(type, handler) { buttonListeners.set(type, handler); },
|
||||
blur() {},
|
||||
};
|
||||
const rafQueue = new Map();
|
||||
let rafId = 0;
|
||||
const requestAnimationFrame = (handler) => {
|
||||
const id = ++rafId;
|
||||
rafQueue.set(id, handler);
|
||||
return id;
|
||||
};
|
||||
const cancelAnimationFrame = (id) => rafQueue.delete(id);
|
||||
const document = {
|
||||
readyState: 'complete',
|
||||
getElementById(id) {
|
||||
if (id === 'chat-messages') return chatEl;
|
||||
if (id === 'chat-return-latest') return returnLatest;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() { return []; },
|
||||
addEventListener() {},
|
||||
};
|
||||
const window = {
|
||||
document,
|
||||
addEventListener() {},
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame,
|
||||
cancelAnimationFrame,
|
||||
innerWidth: 1440,
|
||||
innerHeight: 900,
|
||||
};
|
||||
const context = {
|
||||
window,
|
||||
document,
|
||||
requestAnimationFrame,
|
||||
cancelAnimationFrame,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
console,
|
||||
};
|
||||
vm.runInNewContext(scroll, context);
|
||||
return {
|
||||
api: window.CyberStrikeChatScroll,
|
||||
chatEl,
|
||||
listeners,
|
||||
flushAnimationFrames() {
|
||||
while (rafQueue.size) {
|
||||
const pending = Array.from(rafQueue.values());
|
||||
rafQueue.clear();
|
||||
pending.forEach((handler) => handler(Date.now()));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('向上滚动立即解除粘底,只有滚到真实底部才恢复', () => {
|
||||
const runtime = createScrollRuntime();
|
||||
runtime.flushAnimationFrames();
|
||||
|
||||
runtime.listeners.get('wheel')({ deltaY: -20 });
|
||||
runtime.chatEl.scrollTop = 480;
|
||||
runtime.listeners.get('scroll')();
|
||||
assert.equal(runtime.api.captureScrollPinState(), false);
|
||||
|
||||
runtime.chatEl.scrollHeight = 1100;
|
||||
runtime.api.scrollIfPinned(true);
|
||||
runtime.flushAnimationFrames();
|
||||
assert.equal(runtime.chatEl.scrollTop, 480, '新输出不能抢回用户的阅读位置');
|
||||
|
||||
runtime.chatEl.scrollTop = 597;
|
||||
runtime.listeners.get('scroll')();
|
||||
assert.equal(runtime.api.captureScrollPinState(), false, '距底部 2px 以上仍保持脱离');
|
||||
|
||||
runtime.chatEl.scrollTop = 600;
|
||||
runtime.listeners.get('scroll')();
|
||||
assert.equal(runtime.api.captureScrollPinState(), true, '用户滚到真实底部后立即恢复跟随');
|
||||
|
||||
runtime.chatEl.scrollHeight = 1200;
|
||||
runtime.api.scrollIfPinned(true);
|
||||
runtime.flushAnimationFrames();
|
||||
assert.equal(runtime.chatEl.scrollTop, 1200, '恢复后新增输出继续请求滚到最底部');
|
||||
});
|
||||
|
||||
test('刷新重建详情引起的布局上移不会误判为用户上滑', () => {
|
||||
const runtime = createScrollRuntime();
|
||||
runtime.flushAnimationFrames();
|
||||
|
||||
runtime.chatEl.scrollTop = 460;
|
||||
runtime.listeners.get('scroll')();
|
||||
assert.equal(runtime.api.captureScrollPinState(), true, '没有用户输入的布局滚动仍应保持跟随');
|
||||
|
||||
runtime.chatEl.scrollHeight = 1100;
|
||||
runtime.api.scrollIfPinned(true);
|
||||
runtime.flushAnimationFrames();
|
||||
assert.equal(runtime.chatEl.scrollTop, 1100, '刷新恢复后的后续增量应继续粘底');
|
||||
});
|
||||
|
||||
test('登录成功后重新加载曾因未授权失败的项目侧栏', () => {
|
||||
const refreshSource = functionSource(auth, 'refreshAppData', 'bootstrapApp');
|
||||
const conversationsIndex = refreshSource.indexOf('loadConversations()');
|
||||
const projectRetryIndex = refreshSource.indexOf('window.refreshChatProjectSelector({ reloadFolders: true })');
|
||||
|
||||
assert.notEqual(conversationsIndex, -1);
|
||||
assert.ok(projectRetryIndex > conversationsIndex);
|
||||
assert.match(refreshSource, /typeof window\.refreshChatProjectSelector === 'function'/);
|
||||
assert.match(html, /\/static\/js\/auth\.js\?v=20260813-1/);
|
||||
});
|
||||
|
||||
test('用户真正滑到底部后恢复自动跟随且不会提前强制跳底', () => {
|
||||
const resumeSource = functionSource(scroll, 'resumeFollowingIfAtBottom', 'captureScrollPinState');
|
||||
const captureSource = functionSource(scroll, 'captureScrollPinState', 'setScrollFollowing');
|
||||
const autoSource = functionSource(scroll, 'canAutoScrollNow', 'scheduleChatScrollToBottomIfFollowing');
|
||||
const scrollSource = functionSource(scroll, 'onChatMessagesScroll', 'bindChatScrollListeners');
|
||||
|
||||
assert.match(resumeSource, /thresholdPx/);
|
||||
assert.match(scroll, /CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX = 2/);
|
||||
assert.doesNotMatch(captureSource, /resumeFollowingIfAtBottom/);
|
||||
assert.doesNotMatch(autoSource, /resumeFollowingIfAtBottom/);
|
||||
assert.match(resumeSource, /if \(!userInitiated\) return false/);
|
||||
assert.match(scrollSource, /scrolledDown/);
|
||||
assert.match(scrollSource, /hasUserScrollIntent/);
|
||||
assert.match(scrollSource, /resumeFollowingIfAtBottom\(CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX, true\)/);
|
||||
assert.doesNotMatch(scrollSource, /resumeFollowingIfAtBottom\(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX\)/);
|
||||
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\(\)/);
|
||||
});
|
||||
|
||||
test('切换对话模式引起的布局滚动不会重新开启粘底', () => {
|
||||
const scrollSource = functionSource(scroll, 'onChatMessagesScroll', 'bindChatScrollListeners');
|
||||
const bindSource = functionSource(scroll, 'bindChatScrollListeners', 'initChatScroll');
|
||||
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.doesNotMatch(scrollSource, /else if \(resumeFollowingIfAtBottom\(\)\)/);
|
||||
assert.match(bindSource, /Math\.abs\(e\.deltaY\) > 1/);
|
||||
assert.match(bindSource, /userScrollIntentUntil = Date\.now\(\) \+ 1800/);
|
||||
assert.doesNotMatch(selectModeSource, /setScrollFollowing|forceScrollToBottom|scrollTop/);
|
||||
});
|
||||
|
||||
test('刷新运行中任务补齐最新详情后保持粘底但尊重用户上滑', () => {
|
||||
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
|
||||
const settleSource = functionSource(scroll, 'settleChatToBottomIfFollowing', 'scrollChatMessagesToBottomIfPinned');
|
||||
|
||||
assert.match(attachSource, /window\.captureScrollPinState\(\)/);
|
||||
assert.match(attachSource, /settleToBottomIfFollowing\(12\)/);
|
||||
assert.match(attachSource, /settleToBottomIfFollowing\(18\)/);
|
||||
assert.match(attachSource, /用户期间没有主动上滑/);
|
||||
assert.match(attachSource, /keepFollowingFinalRender/);
|
||||
assert.match(attachSource, /最终消息和详情重绘都会增高 DOM/);
|
||||
assert.match(settleSource, /scrollMode !== 'following'/);
|
||||
assert.match(settleSource, /Date\.now\(\) < detachLockUntil/);
|
||||
assert.match(settleSource, /settleFrame\(remaining - 1\)/);
|
||||
assert.match(settleSource, /scrollChatToBottomInstant\(\)/);
|
||||
assert.match(scroll, /function settleConversationRestoreToBottom\(frameCount\)/);
|
||||
assert.match(scroll, /CONVERSATION_RESTORE_SETTLE_MIN_MS = 3000/);
|
||||
assert.match(scroll, /CONVERSATION_RESTORE_SETTLE_MAX_MS = 6000/);
|
||||
assert.match(scroll, /const generation = \+\+conversationRestoreGeneration/);
|
||||
assert.match(scroll, /scrollMode !== 'following'/);
|
||||
assert.match(scroll, /stableFrames >= CONVERSATION_RESTORE_STABLE_FRAMES/);
|
||||
assert.match(scroll, /requestAnimationFrame\(settleRestoreFrame\)/);
|
||||
assert.match(chat, /settleConversationRestoreToBottom\(30\)/);
|
||||
});
|
||||
|
||||
test('刷新后迭代思考区独立跟随最新内容且允许用户上滑解除', () => {
|
||||
const startSource = functionSource(monitor, 'startProcessDetailsLatestFollow', 'loadProcessDetailsPaginated');
|
||||
const loadSource = functionSource(monitor, 'loadProcessDetailsPaginated', 'shouldInitiallyOpenProcessDetailsAtLatest');
|
||||
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
|
||||
|
||||
assert.match(startSource, /new MutationObserver\(scheduleFollowLatest\)/);
|
||||
assert.match(startSource, /characterData: true/);
|
||||
assert.match(startSource, /new ResizeObserver\(scheduleFollowLatest\)/);
|
||||
assert.match(startSource, /scrollProcessDetailsToLatest\(String\(assistantMessageId \|\| ''\), false\)/);
|
||||
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(monitor, /PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX = 2/);
|
||||
assert.match(startSource, /distance <= PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX/);
|
||||
assert.match(startSource, /state\.detached = false/);
|
||||
assert.doesNotMatch(startSource, /if \(distance <= PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX\) \{\s*state\.detached = false/);
|
||||
assert.match(loadSource, /startProcessDetailsLatestFollow\(assistantMessageId/);
|
||||
assert.match(attachSource, /startProcessDetailsLatestFollow\(asEl\.id, \{ persistent: true \}\)/);
|
||||
assert.match(attachSource, /stopProcessDetailsLatestFollow\(asEl\.id\)/);
|
||||
});
|
||||
|
||||
test('刷新后的工具调用恢复与实时一致的成功失败徽标', () => {
|
||||
const renderSource = functionSource(chat, 'renderProcessDetails', 'finishProcessDetailsRender');
|
||||
const presentationSource = functionSource(monitor, 'getToolCallStatusPresentation', 'applyToolCallStatus');
|
||||
const applySource = functionSource(monitor, 'applyToolCallStatus', 'updateToolCallStatus');
|
||||
const addSource = functionSource(monitor, 'addTimelineItem', 'loadActiveTasks');
|
||||
|
||||
assert.match(renderSource, /toolStatusByProcessDetailId/);
|
||||
assert.match(renderSource, /timelineOpts\.toolStatus = toolStatusByProcessDetailId\.get/);
|
||||
assert.match(presentationSource, /normalized === 'completed'/);
|
||||
assert.match(presentationSource, /normalized === 'failed'/);
|
||||
assert.match(applySource, /tool-status-badge/);
|
||||
assert.match(applySource, /item\.dataset\.toolDisplayStatus = presentation\.status/);
|
||||
assert.match(addSource, /initialToolStatus = item\.dataset\.toolDisplayStatus/);
|
||||
assert.match(addSource, /applyToolCallStatus\(item, initialToolStatus\)/);
|
||||
assert.match(monitor, /refreshProgressAndTimelineI18n\(\)[\s\S]*?applyToolCallStatus\(item, item\.dataset\.toolDisplayStatus\)/);
|
||||
});
|
||||
|
||||
test('首次实时输出与刷新恢复都保留独立迭代滚动并跟随最新内容', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
const addSource = functionSource(monitor, 'addProgressMessage', 'toggleProgressDetails');
|
||||
const liveSource = functionSource(monitor, 'startLiveProgressLatestFollow', 'stopLiveProgressLatestFollow');
|
||||
|
||||
assert.match(css, /\.progress-container\.is-streaming \.progress-timeline\.expanded,[\s\S]{0,360}max-height: min\(64vh, 720px\);[\s\S]{0,180}overflow-y: auto;/);
|
||||
assert.match(css, /\.message\.progress-message \.progress-timeline\.expanded \{[\s\S]{0,260}max-height: min\(64vh, 720px\);[\s\S]{0,160}overflow-y: auto;/);
|
||||
assert.doesNotMatch(css, /流式执行中[\s\S]{0,320}overflow-y: visible;/);
|
||||
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\)/);
|
||||
});
|
||||
|
||||
test('同一会话的其他标签页自动补流且发送前阻止重复任务', () => {
|
||||
const syncSource = functionSource(monitor, 'syncVisibleConversationTaskReplay', 'getActiveTaskDisplayName');
|
||||
const sendSource = functionSource(chat, 'sendMessage', 'renderChatFileChips');
|
||||
|
||||
assert.match(monitor, /new BroadcastChannel\(CHAT_TASK_SYNC_CHANNEL_NAME\)/);
|
||||
assert.match(monitor, /payload\.type !== 'task-started'/);
|
||||
assert.match(monitor, /conversationExecutionTracker\.markRunning\(id\)/);
|
||||
assert.match(syncSource, /await window\.loadConversation\(conversationId\)/);
|
||||
assert.match(syncSource, /return attachRunningTaskEventStream\(conversationId\)/);
|
||||
assert.match(monitor, /syncVisibleConversationTaskReplay\(normalizedTasks\)/);
|
||||
assert.match(sendSource, /await loadActiveTasks\(\)/);
|
||||
assert.match(sendSource, /if \(isCurrentChatTaskActive\(\)\)/);
|
||||
assert.ok(sendSource.indexOf('if (isCurrentChatTaskActive())') < sendSource.indexOf("addMessage('user'"));
|
||||
assert.match(sendSource, /window\.notifyConversationTaskStarted\(streamConversationId\)/);
|
||||
});
|
||||
|
||||
test('刷新补流在订阅竞态或终态帧丢失时从数据库对账最终正文', () => {
|
||||
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
|
||||
const reconcileSource = functionSource(monitor, 'reconcileConversationAfterTaskReplay', 'cancelRunningTaskEventStream');
|
||||
|
||||
assert.match(attachSource, /const eventStreamResponsePromise = apiFetch\(url/);
|
||||
assert.ok(attachSource.indexOf('const eventStreamResponsePromise') < attachSource.indexOf('loadProcessDetailsPaginated'));
|
||||
assert.match(attachSource, /if \(!active\) \{[\s\S]*?assistantMessageNeedsTaskReplayReconcile\(staleAssistant\)[\s\S]*?reconcileConversationAfterTaskReplay\(conversationId, true\)/);
|
||||
assert.match(attachSource, /if \(!response\.ok\) \{[\s\S]*?reconcileConversationAfterTaskReplay\(conversationId, true\)/);
|
||||
assert.match(attachSource, /if \(!replaySawDone\) \{[\s\S]*?reconcileConversationAfterTaskReplay/);
|
||||
assert.match(reconcileSource, /updateAssistantBubbleContent\(assistantEl\.id, finalMessage\.content \|\| '', true\)/);
|
||||
assert.match(reconcileSource, /loadProcessDetailsPaginated\(assistantEl\.id, finalMessage\.id,[\s\S]*?initialLatest: true,[\s\S]*?autoLoadAll: false/);
|
||||
});
|
||||
|
||||
test('消息气泡内部流式增高时仅在跟随模式继续粘底', () => {
|
||||
const bindSource = functionSource(scroll, 'bindChatScrollListeners', 'initChatScroll');
|
||||
|
||||
assert.match(bindSource, /scrollMode === 'following'/);
|
||||
assert.match(bindSource, /scheduleChatScrollToBottomIfFollowing\(true\)/);
|
||||
assert.match(bindSource, /\{ childList: true, subtree: true, characterData: true \}/);
|
||||
assert.match(bindSource, /new ResizeObserver/);
|
||||
assert.match(bindSource, /chatMessagesResizeObserver\.observe\(el\)/);
|
||||
assert.match(bindSource, /改变消息区 clientHeight/);
|
||||
assert.match(bindSource, /Math\.abs\(e\.deltaY\) > 1/);
|
||||
assert.match(bindSource, /e\.deltaY < -1/);
|
||||
assert.match(bindSource, /e\.clientX >= rect\.right - 18/);
|
||||
assert.match(bindSource, /e\.key === 'ArrowUp'/);
|
||||
});
|
||||
|
||||
test('页面在任务补流脚本之前加载智能滚动控制器', () => {
|
||||
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260813-6');
|
||||
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260813-9');
|
||||
|
||||
assert.notEqual(scrollIndex, -1);
|
||||
assert.notEqual(monitorIndex, -1);
|
||||
assert.ok(scrollIndex < monitorIndex);
|
||||
});
|
||||
|
||||
test('直接点击项目对话也会写入 hash 以便刷新后恢复并补流', () => {
|
||||
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
|
||||
const syncSource = functionSource(chat, 'syncChatConversationHash', 'getConversationLiteFromCache');
|
||||
const streamSource = functionSource(monitor, 'setCurrentConversationIdFromStream', 'shouldSkipTaskEventReplayAttach');
|
||||
|
||||
assert.match(syncSource, /window\.location\.hash\.split\('\?'\)\[0\] !== '#chat'/);
|
||||
assert.match(syncSource, /#chat\?conversation=/);
|
||||
assert.match(syncSource, /window\.history\.replaceState/);
|
||||
assert.match(loadSource, /syncChatConversationHash\(conversationId\)/);
|
||||
assert.match(streamSource, /window\.syncChatConversationHash\(cid\)/);
|
||||
});
|
||||
|
||||
test('刷新指定对话时立即恢复且加载完成前不闪出无项目状态', () => {
|
||||
const scheduleSource = functionSource(router, 'scheduleChatConversationFromHash', 'navigateToConversation');
|
||||
const restoreStateSource = functionSource(router, 'setChatConversationRestorePending', 'finishChatConversationRestore');
|
||||
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
|
||||
assert.match(router, /scheduleChatConversationFromHash\(0\)/);
|
||||
assert.doesNotMatch(router, /scheduleChatConversationFromHash\((200|500)\)/);
|
||||
assert.match(scheduleSource, /setChatConversationRestorePending\(conversationId, true\)/);
|
||||
assert.match(restoreStateSource, /is-conversation-restoring/);
|
||||
assert.match(restoreStateSource, /aria-busy/);
|
||||
assert.match(loadSource, /finally \{[\s\S]*?finishChatConversationRestore\(conversationId\)/);
|
||||
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-messages/);
|
||||
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-input-container/);
|
||||
assert.match(html, /router\.js\?v=20260813-2/);
|
||||
assert.match(html, /chat\.js\?v=20260813-3/);
|
||||
});
|
||||
|
||||
test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => {
|
||||
const findSource = functionSource(monitor, 'findRestoredMainResponseStreamItem', 'responseStreamStateFromRestoredItem');
|
||||
const handleSource = functionSource(monitor, 'handleStreamEvent', 'hitlApprovalTranslate');
|
||||
|
||||
assert.match(findSource, /timeline-item-planning/);
|
||||
assert.match(findSource, /dataset\.responseStreamId/);
|
||||
assert.match(handleSource, /case 'response_start':[\s\S]*?findRestoredMainResponseStreamItem/);
|
||||
assert.match(handleSource, /case 'response_delta':[\s\S]*?responseStreamStateFromRestoredItem/);
|
||||
assert.match(monitor, /item\.dataset\.responseStreamId = String\(options\.data\.streamId\)/);
|
||||
});
|
||||
|
||||
test('非仪表盘 hash 首屏在路由确定前隐藏默认仪表盘', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
assert.match(html, /document\.documentElement\.classList\.add\('initial-route-pending'\)/);
|
||||
assert.match(router, /document\.documentElement\.classList\.remove\('initial-route-pending'\)/);
|
||||
assert.match(css, /html\.initial-route-pending \.content-area \{[\s\S]*?visibility: hidden;/);
|
||||
});
|
||||
|
||||
test('刷新恢复运行中助手消息时隐藏处理中占位且终态正文会重新显示', () => {
|
||||
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
|
||||
const updateSource = functionSource(monitor, 'updateAssistantBubbleContent', 'isConversationTaskRunning');
|
||||
|
||||
assert.match(loadSource, /hideAssistantPlaceholder: isAssistantPlaceholder/);
|
||||
assert.match(chat, /bubble\.hidden = true/);
|
||||
assert.match(updateSource, /assistant-placeholder-content/);
|
||||
assert.match(updateSource, /bubble\.hidden = false/);
|
||||
});
|
||||
|
||||
test('刷新补流任务完成后强制折叠自动展开的迭代详情', () => {
|
||||
const collapseSource = functionSource(monitor, 'collapseAllProgressDetails', 'getAssistantId');
|
||||
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
|
||||
|
||||
assert.match(collapseSource, /options/);
|
||||
assert.match(collapseSource, /forceCollapse/);
|
||||
assert.match(collapseSource, /delete detailsContainer\.dataset\.userExpanded/);
|
||||
assert.match(attachSource, /collapseAllProgressDetails\(finalAssistant\.id, progressId, \{ force: true \}\)/);
|
||||
assert.doesNotMatch(attachSource, /if \(keepExpanded\)/);
|
||||
});
|
||||
|
||||
test('暗色模式用户气泡使用协调的深蓝灰层级', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
assert.match(css, /html\[data-theme="dark"\] \.message\.user \.message-bubble \{[\s\S]*?background: #1b2638;/);
|
||||
assert.match(css, /border-color: rgba\(96, 165, 250, 0\.18\)/);
|
||||
});
|
||||
|
||||
test('暗色模式对话三点悬浮不会触发浅色父行背景', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
assert.match(css, /html\[data-theme="dark"\] \.project-conversation-row:hover \.project-conversation-item/);
|
||||
assert.match(css, /html\[data-theme="dark"\] \.project-folder-action:hover,[\s\S]*?background: rgba\(71, 85, 105, 0\.28\);[\s\S]*?box-shadow: none;/);
|
||||
assert.match(html, /style\.css\?v=20260813-5/);
|
||||
});
|
||||
Reference in New Issue
Block a user