修复多会话任务栏抖动、会话跳回与停止失效 (#264)

* fix(web): stabilize active task ordering

* fix(chat): preserve navigation during conversation startup

* fix(tasks): guarantee hard cancellation

* fix(chat): bind navigation and hard stop targets

* fix(chat): prevent replay from reclaiming navigation
This commit is contained in:
RuoJi6
2026-08-19 13:54:30 +08:00
committed by GitHub
parent c7cc0bc9da
commit bec2d2faf1
10 changed files with 344 additions and 36 deletions
+73 -2
View File
@@ -6,6 +6,7 @@ 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 projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
const router = fs.readFileSync('web/static/js/router.js', 'utf8');
const auth = fs.readFileSync('web/static/js/auth.js', 'utf8');
const webshell = fs.readFileSync('web/static/js/webshell.js', 'utf8');
@@ -383,6 +384,76 @@ test('任务计划进度事件在活跃任务列表变化和新任务开始时
assert.match(renderSource, /detail: \{ tasks: normalizedTasks \}/);
});
test('活跃任务按启动时间稳定排列且无变化刷新不重建停止按钮', () => {
const sortSource = functionSource(monitor, 'stableActiveTasksForDisplay', 'activeTasksRenderSignature');
const sortTasks = vm.runInNewContext(`(${sortSource.trim()})`);
const tasks = [
{ conversationId: 'conversation-z', startedAt: '2026-08-19T10:00:00Z' },
{ conversationId: 'conversation-late', startedAt: '2026-08-19T10:01:00Z' },
{ conversationId: 'conversation-a', startedAt: '2026-08-19T10:00:00Z' }
];
assert.deepEqual(
Array.from(sortTasks(tasks), task => task.conversationId),
['conversation-a', 'conversation-z', 'conversation-late']
);
const renderSource = functionSource(monitor, 'renderActiveTasks', 'reconcileHitlApprovalStateWithActiveTasks');
assert.match(renderSource, /nextVisualSignature === activeTasksVisualSignature/);
assert.match(renderSource, /bar\.querySelectorAll\('\.active-task-item'\)\.length === normalizedTasks\.length/);
assert.match(renderSource, /const previousScrollLeft = bar\.scrollLeft/);
assert.match(renderSource, /bar\.scrollLeft = previousScrollLeft/);
});
test('新对话初始化期间切换会话后旧流事件不能把页面拉回', () => {
const guardSource = functionSource(chat, 'shouldIgnoreLiveChatStreamEvent', 'clearLiveChatStreamIfOwned');
const shouldIgnore = vm.runInNewContext(`(${guardSource.trim()})`);
const activeStream = { active: true, detached: false, navigationSeq: 7 };
assert.equal(shouldIgnore(activeStream, activeStream, 7), false);
activeStream.detached = true;
assert.equal(shouldIgnore(activeStream, activeStream, 7), true);
activeStream.detached = false;
activeStream.active = false;
assert.equal(shouldIgnore(activeStream, activeStream, 7), true);
activeStream.active = true;
assert.equal(shouldIgnore(activeStream, activeStream, 8), true);
assert.equal(shouldIgnore({ active: true, detached: false, navigationSeq: 7 }, activeStream, 7), true);
const sendSource = functionSource(chat, 'sendMessage', 'renderChatFileChips');
const guardIndex = sendSource.indexOf('shouldIgnoreLiveChatStreamEvent(liveStreamState)');
const handlerIndex = sendSource.indexOf('handleStreamEvent(eventData');
assert.notEqual(guardIndex, -1);
assert.notEqual(handlerIndex, -1);
assert.ok(guardIndex < handlerIndex);
assert.match(sendSource, /const requestNavigationSeq = chatConversationNavigationSeq;[\s\S]*?await loadActiveTasks\(\)/);
assert.match(sendSource, /if \(requestNavigationSeq !== chatConversationNavigationSeq\) \{[\s\S]{0,80}return;/);
assert.match(sendSource, /navigationSeq: requestNavigationSeq/);
assert.match(sendSource, /if \(!streamConversationId\) \{[\s\S]{0,180}liveStreamState\.conversationId = eventConvId/);
assert.match(sendSource, /if \(eventConvId\) updateProgressConversation\(progressId, eventConvId\);[\s\S]{0,80}return;/);
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
const newConversationSource = functionSource(chat, 'startNewConversation', 'loadConversations');
assert.match(loadSource, /markChatConversationNavigation\(conversationId\)/);
assert.match(loadSource, /window\.cancelScheduledChatConversationFromHash\(\)/);
assert.match(newConversationSource, /markChatConversationNavigation\('', true\)/);
assert.match(newConversationSource, /clearChatConversationHash\(\)/);
assert.match(router, /function cancelScheduledChatConversationFromHash\(\)[\s\S]{0,160}chatConversationFromHashSeq\+\+/);
assert.match(chat, /function abandonChatConversationForPageNavigation\(\)[\s\S]{0,260}markChatConversationNavigation\('', true\)/);
assert.match(chat, /abandonChatConversationForPageNavigation\(\)[\s\S]{0,420}detachLiveChatStreamForNavigation\('', true\)/);
assert.match(router, /currentPage === 'chat'[\s\S]{0,140}window\.abandonChatConversationForPageNavigation\(\)/);
assert.match(chat, /const targetConversationId = String\(item\.dataset\.conversationId \|\| ''\)\.trim\(\);[\s\S]{0,80}loadConversation\(targetConversationId\)/);
assert.match(projects, /const targetConversationId = String\(event\.currentTarget && event\.currentTarget\.dataset\.conversationId \|\| ''\)\.trim\(\)/);
assert.match(projects, /window\.loadConversation\(targetConversationId\)/);
assert.match(chat, /let loadConversationPendingId = ''/);
assert.match(chat, /window\.isChatConversationLoadPending = isChatConversationLoadPending/);
const immediateSelectionIndex = loadSource.indexOf('currentConversationId = conversationId;');
const conversationFetchIndex = loadSource.indexOf('await apiFetch(`/api/conversations/${conversationId}?include_process_details=0`');
assert.notEqual(immediateSelectionIndex, -1);
assert.notEqual(conversationFetchIndex, -1);
assert.ok(immediateSelectionIndex < conversationFetchIndex);
assert.match(monitor, /String\(window\.currentConversationId \|\| ''\) !== conversationId[\s\S]{0,300}window\.isChatConversationLoadPending\(conversationId\)/);
});
test('刷新指定对话时立即恢复且加载完成前不闪出无项目状态', () => {
const scheduleSource = functionSource(router, 'scheduleChatConversationFromHash', 'navigateToConversation');
const restoreStateSource = functionSource(router, 'setChatConversationRestorePending', 'finishChatConversationRestore');
@@ -397,8 +468,8 @@ test('刷新指定对话时立即恢复且加载完成前不闪出无项目状
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=20260819-1/);
assert.match(html, /router\.js\?v=20260819-3/);
assert.match(html, /chat\.js\?v=20260819-5/);
});
test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => {
+112 -13
View File
@@ -10,8 +10,46 @@ function syncChatConversationHash(conversationId) {
}
}
window.syncChatConversationHash = syncChatConversationHash;
function clearChatConversationHash() {
if (window.location.hash.split('?')[0] !== '#chat' || window.location.hash === '#chat') return;
window.history.replaceState(null, '', '#chat');
}
window.clearChatConversationHash = clearChatConversationHash;
let loadConversationRequestSeq = 0;
let loadConversationAbortController = null;
let loadConversationPendingId = '';
let chatConversationNavigationSeq = 0;
function isChatConversationLoadPending(conversationId) {
const id = String(conversationId || '').trim();
return !!id && loadConversationPendingId === id;
}
window.isChatConversationLoadPending = isChatConversationLoadPending;
function markChatConversationNavigation(nextConversationId, force = false) {
const nextId = String(nextConversationId || '').trim();
const visibleId = String(currentConversationId || '').trim();
if (force || nextId !== visibleId) {
chatConversationNavigationSeq++;
}
return chatConversationNavigationSeq;
}
/**
* 离开聊天页时立即让尚在初始化的发送请求失去页面所有权
* 后端任务仍会继续执行这里只中止浏览器前台流避免首个 conversation
* 事件在用户已经切到其他页面后再次抢占当前会话
*/
function abandonChatConversationForPageNavigation() {
markChatConversationNavigation('', true);
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
window.cancelScheduledChatConversationFromHash();
}
cancelPendingConversationLoad();
detachLiveChatStreamForNavigation('', true);
}
window.abandonChatConversationForPageNavigation = abandonChatConversationForPageNavigation;
/**
* 轻量会话 LRU 缓存
@@ -1768,6 +1806,18 @@ function ownsLiveChatStream(liveStream) {
return !!liveStream && window.__csAgentLiveStream === liveStream;
}
function shouldIgnoreLiveChatStreamEvent(
liveStream,
activeLiveStream = window.__csAgentLiveStream,
navigationSeq = chatConversationNavigationSeq
) {
return !liveStream ||
activeLiveStream !== liveStream ||
liveStream.active !== true ||
liveStream.detached === true ||
liveStream.navigationSeq !== navigationSeq;
}
function clearLiveChatStreamIfOwned(liveStream) {
if (!ownsLiveChatStream(liveStream)) return false;
liveStream.active = false;
@@ -2208,6 +2258,8 @@ async function sendMessage() {
const input = document.getElementById('chat-input');
let message = input.value.trim();
const hasAttachments = chatAttachments && chatAttachments.length > 0;
const requestConversationId = currentConversationId;
const requestNavigationSeq = chatConversationNavigationSeq;
if (!message && !hasAttachments) {
return;
@@ -2261,6 +2313,12 @@ async function sendMessage() {
message = CHAT_FILE_DEFAULT_PROMPT;
}
// 发送前的任务状态/附件检查可能包含异步等待。若用户已主动切换会话,
// 保留当前页面,不再把这次尚未发出的请求写入新的可见对话。
if (requestNavigationSeq !== chatConversationNavigationSeq) {
return;
}
// 显示用户消息(含附件名,便于用户确认)
const displayMessage = hasAttachments
? message + '\n' + chatAttachments.map(a => '📎 ' + a.fileName).join('\n')
@@ -2296,7 +2354,7 @@ async function sendMessage() {
// 构建请求体(含附件)
const body = {
message: message,
conversationId: currentConversationId,
conversationId: requestConversationId,
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
};
if (window.__csNextChatFinalizationPolicy && typeof window.__csNextChatFinalizationPolicy === 'object') {
@@ -2357,7 +2415,8 @@ async function sendMessage() {
conversationId: streamConversationId || null,
progressId: progressId,
abortController: requestAbortController,
detached: false
detached: false,
navigationSeq: requestNavigationSeq
};
window.__csAgentLiveStream = liveStreamState;
if (streamConversationId && typeof window.notifyConversationTaskStarted === 'function') {
@@ -2408,18 +2467,18 @@ async function sendMessage() {
if (streamConversationId && streamConversationId !== eventConvId) {
return;
}
if (!streamConversationId && eventData.type === 'conversation') {
if (!streamConversationId) {
streamConversationId = eventConvId;
liveStreamState.conversationId = eventConvId;
justBoundConversation = true;
// 旧请求可能在用户切换对话后才收到 conversation 事件。
// 只完成本地任务绑定,不允许它重新抢占当前对话或新的主流状态。
if (!ownsLiveChatStream(liveStreamState) || liveStreamState.detached) {
updateProgressConversation(progressId, eventConvId);
return;
}
}
}
// 切换对话后仍可能收到旧响应流中已缓冲的 conversation、response_start
// 或 response 事件。它们只能补齐后台任务归属,不能重新抢占当前对话。
if (shouldIgnoreLiveChatStreamEvent(liveStreamState)) {
if (eventConvId) updateProgressConversation(progressId, eventConvId);
return;
}
if (!justBoundConversation && !isStreamStillVisibleForRequest()) {
return;
}
@@ -5652,6 +5711,11 @@ async function startNewConversation(options = {}) {
const requestedProjectId = hasExplicitProjectId
? String(options.projectId || '').trim()
: String(inheritedProjectId || '').trim();
markChatConversationNavigation('', true);
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
window.cancelScheduledChatConversationFromHash();
}
clearChatConversationHash();
cancelPendingConversationLoad();
detachLiveChatStreamForNavigation('', true);
if (typeof window.cancelRunningTaskEventStream === 'function') {
@@ -5776,7 +5840,8 @@ function createConversationListItem(conversation) {
item.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
loadConversation(conversation.id);
const targetConversationId = String(item.dataset.conversationId || '').trim();
if (targetConversationId) loadConversation(targetConversationId);
};
return item;
}
@@ -6092,16 +6157,30 @@ async function prefetchLastAssistantProcessDetails() {
}
async function loadConversation(conversationId) {
conversationId = String(conversationId || '').trim();
if (!conversationId) return;
// Keep the visible conversation addressable across a full page refresh.
// Sidebar/project entries call loadConversation directly (rather than the
// router helper), so without this synchronization #chat loses the active
// conversation and reload falls back to the welcome screen instead of
// reconnecting the running task event stream.
markChatConversationNavigation(conversationId);
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
window.cancelScheduledChatConversationFromHash();
}
syncChatConversationHash(conversationId);
const seq = ++loadConversationRequestSeq;
const previousConversationId = currentConversationId;
cancelPendingConversationLoad();
detachLiveChatStreamForNavigation(conversationId);
// 用户单击即代表新的可见会话。必须在任何网络等待之前提交该选择,
// 否则每 2 秒的活跃任务刷新仍会把旧会话识别为可见,并排队重载旧补流,
// 反过来取消这次切换。
currentConversationId = conversationId;
try {
window.currentConversationId = conversationId;
} catch (e) { /* ignore */ }
loadConversationPendingId = conversationId;
const conversationLoadController = new AbortController();
loadConversationAbortController = conversationLoadController;
if (typeof window.selectChatProjectConversationItem === 'function') {
@@ -6132,6 +6211,14 @@ async function loadConversation(conversationId) {
return;
}
if (response && !response.ok) {
if (seq === loadConversationRequestSeq) {
currentConversationId = previousConversationId;
try {
window.currentConversationId = previousConversationId || '';
} catch (e) { /* ignore */ }
if (previousConversationId) syncChatConversationHash(previousConversationId);
else clearChatConversationHash();
}
showChatToast('加载对话失败: ' + (conversation.error || '未知错误'), 'error');
return;
}
@@ -6418,8 +6505,16 @@ async function loadConversation(conversationId) {
}
} catch (error) {
if (error && error.name === 'AbortError') return;
if (seq === loadConversationRequestSeq && typeof window.selectChatProjectConversationItem === 'function') {
window.selectChatProjectConversationItem(previousConversationId);
if (seq === loadConversationRequestSeq) {
currentConversationId = previousConversationId;
try {
window.currentConversationId = previousConversationId || '';
} catch (e) { /* ignore */ }
if (previousConversationId) syncChatConversationHash(previousConversationId);
else clearChatConversationHash();
if (typeof window.selectChatProjectConversationItem === 'function') {
window.selectChatProjectConversationItem(previousConversationId);
}
}
console.error('加载对话失败:', error);
showChatToast('加载对话失败: ' + (error && error.message ? error.message : String(error)), 'error');
@@ -6430,6 +6525,9 @@ async function loadConversation(conversationId) {
if (loadConversationAbortController === conversationLoadController) {
loadConversationAbortController = null;
}
if (seq === loadConversationRequestSeq && loadConversationPendingId === conversationId) {
loadConversationPendingId = '';
}
}
}
@@ -10093,7 +10191,8 @@ function createConversationListItemWithMenu(conversation, isPinned) {
if (currentGroupId) {
exitGroupDetail();
}
loadConversation(conversation.id);
const targetConversationId = String(item.dataset.conversationId || '').trim();
if (targetConversationId) loadConversation(targetConversationId);
};
return item;
+16 -4
View File
@@ -280,7 +280,7 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
assert.match(chat, /liveStream\.detached = true;[\s\S]{0,240}controller\.abort\(\)/);
assert.match(chat, /const requestAbortController = new AbortController\(\)/);
assert.match(chat, /signal: requestAbortController\.signal/);
assert.match(chat, /if \(!ownsLiveChatStream\(liveStreamState\) \|\| liveStreamState\.detached\)/);
assert.match(chat, /shouldIgnoreLiveChatStreamEvent\(liveStreamState\)/);
assert.match(chat, /const clearedOwnedStream = clearLiveChatStreamIfOwned\(liveStreamState\)/);
assert.match(chat, /detachLiveChatStreamForNavigation\(conversationId\)/);
assert.match(chat, /detachLiveChatStreamForNavigation\('', true\)/);
@@ -289,14 +289,26 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
assert.match(monitor, /function scrollProcessDetailsToLatest\(assistantMessageId, smooth = true\)/);
assert.match(monitor, /timeline\.scrollTop = targetTop/);
assert.match(chat, /let loadConversationAbortController = null/);
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,220}const conversationLoadController = new AbortController\(\)/);
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,900}const conversationLoadController = new AbortController\(\)/);
assert.match(chat, /signal: conversationLoadController\.signal/);
assert.match(template, /monitor\.js\?v=20260819-3/);
assert.match(template, /chat-scroll\.js\?v=20260815-1/);
assert.match(template, /chat\.js\?v=20260819-1/);
assert.match(template, /chat\.js\?v=20260819-5/);
assert.match(template, /style\.css\?v=20260819-4/);
});
test('彻底停止始终使用弹窗锁定的会话且状态刷新后仍会取消', () => {
const start = monitor.indexOf("async function performHardCancelProgressTask(progressId, conversationId = '')");
const end = monitor.indexOf('function progressElapsedText(', start);
assert.notEqual(start, -1);
assert.notEqual(end, -1);
const hardCancelSource = monitor.slice(start, end);
assert.match(monitor, /performHardCancelProgressTask\(progressId, conversationId\)/);
assert.match(hardCancelSource, /const targetConversationId = String\(conversationId \|\| \(state && state\.conversationId\) \|\| ''\)\.trim\(\)/);
assert.match(hardCancelSource, /await requestCancel\(targetConversationId\)/);
assert.doesNotMatch(hardCancelSource, /if \(!state \|\| !state\.conversationId\)/);
});
test('输入区 Agent 审查文字保留足够行高且不会裁切字形', () => {
assert.match(styles, /\.chat-hitl-shortcut > span\s*\{[\s\S]*?display: block/);
assert.match(styles, /\.chat-hitl-shortcut > span\s*\{[\s\S]*?padding-block: 1px/);
@@ -339,7 +351,7 @@ test('审批状态主动轮询并在服务不可用时立即关闭旧审批', ()
assert.match(monitor, /renderActiveTasks\(\[\]\);[\s\S]{0,260}hitlPendingInterruptTracker\.update\(\[\]\)/);
assert.match(projects, /function syncProjectConversationApprovalStatuses\(items\)/);
assert.match(projects, /window\.syncProjectConversationApprovalStatuses/);
assert.match(template, /projects\.js\?v=20260812-6/);
assert.match(template, /projects\.js\?v=20260819-1/);
});
test('旧会话首次升级到五分钟默认审批时限,仍允许用户之后主动选择不限时', () => {
+57 -6
View File
@@ -6,6 +6,7 @@ const ACTIVE_TASK_REFRESH_INTERVAL = 2000; // 运行态与审批态需要及时
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
const hitlInterruptToolItemMap = new Map();
let activeTasksLoadPromise = null;
let activeTasksVisualSignature = '';
const CHAT_TASK_SYNC_CHANNEL_NAME = 'cyberstrike-chat-task-sync-v1';
let chatTaskSyncChannel = null;
let visibleConversationReplaySyncPromise = null;
@@ -1432,7 +1433,7 @@ async function submitUserInterruptHardCancel() {
const { progressId, conversationId } = userInterruptModalPending;
closeUserInterruptModal();
if (progressId) {
await performHardCancelProgressTask(progressId);
await performHardCancelProgressTask(progressId, conversationId);
return;
}
if (!conversationId) {
@@ -1448,11 +1449,12 @@ async function submitUserInterruptHardCancel() {
}
/** 彻底停止任务(原「停止任务」行为) */
async function performHardCancelProgressTask(progressId) {
async function performHardCancelProgressTask(progressId, conversationId = '') {
const state = progressTaskState.get(progressId);
const stopBtn = document.getElementById(`${progressId}-stop-btn`);
const targetConversationId = String(conversationId || (state && state.conversationId) || '').trim();
if (!state || !state.conversationId) {
if (!targetConversationId) {
if (stopBtn) {
stopBtn.disabled = true;
setTimeout(() => {
@@ -1463,7 +1465,7 @@ async function performHardCancelProgressTask(progressId) {
return;
}
if (state.cancelling) {
if (state && state.cancelling) {
return;
}
@@ -1474,7 +1476,7 @@ async function performHardCancelProgressTask(progressId) {
}
try {
await requestCancel(state.conversationId);
await requestCancel(targetConversationId);
loadActiveTasks();
} catch (error) {
console.error('取消任务失败:', error);
@@ -6858,6 +6860,17 @@ function syncVisibleConversationTaskReplay(tasks) {
visibleConversationReplaySyncId = conversationId;
visibleConversationReplaySyncPromise = Promise.resolve()
.then(async function () {
// 用户可能在任务刷新排队后、此微任务执行前切换了会话。
// 不允许旧会话补流取消或覆盖用户刚发起的目标会话加载。
if (String(window.currentConversationId || '') !== conversationId) {
return false;
}
if (
typeof window.isChatConversationLoadPending === 'function' &&
window.isChatConversationLoadPending(conversationId)
) {
return false;
}
// 另一标签页已新增用户消息和运行中助手轮次;先重载轻量历史,避免把补流挂到旧助手消息上。
if (typeof window.loadConversation === 'function') {
await window.loadConversation(conversationId);
@@ -6893,6 +6906,33 @@ function getActiveTaskDisplayName(task) {
return message || unnamedTaskText;
}
function stableActiveTasksForDisplay(tasks) {
return (Array.isArray(tasks) ? tasks : []).slice().sort(function (a, b) {
const aStartedAt = Date.parse(a && a.startedAt ? a.startedAt : '');
const bStartedAt = Date.parse(b && b.startedAt ? b.startedAt : '');
const aTime = Number.isFinite(aStartedAt) ? aStartedAt : Number.MAX_SAFE_INTEGER;
const bTime = Number.isFinite(bStartedAt) ? bStartedAt : Number.MAX_SAFE_INTEGER;
if (aTime !== bTime) return aTime - bTime;
return String(a && a.conversationId || '').localeCompare(String(b && b.conversationId || ''));
});
}
function activeTasksRenderSignature(tasks) {
const language = typeof i18next !== 'undefined' && i18next.language ? i18next.language : getCurrentTimeLocale();
return JSON.stringify({
language: language,
tasks: (Array.isArray(tasks) ? tasks : []).map(function (task) {
return {
conversationId: task && task.conversationId || '',
title: task && task.title || '',
message: task && task.message || '',
startedAt: task && task.startedAt || '',
status: task && task.status || ''
};
})
});
}
function updateActiveTaskConversationTitle(conversationId, newTitle) {
const bar = document.getElementById('active-tasks-bar');
if (!bar || !conversationId) return;
@@ -6909,7 +6949,7 @@ function renderActiveTasks(tasks) {
const bar = document.getElementById('active-tasks-bar');
if (!bar) return;
const normalizedTasks = Array.isArray(tasks) ? tasks : [];
const normalizedTasks = stableActiveTasksForDisplay(tasks);
conversationExecutionTracker.update(normalizedTasks);
window.dispatchEvent(new CustomEvent('conversation-task-state-changed', {
detail: { tasks: normalizedTasks }
@@ -6930,10 +6970,20 @@ function renderActiveTasks(tasks) {
if (normalizedTasks.length === 0) {
bar.style.display = 'none';
bar.innerHTML = '';
activeTasksVisualSignature = '';
return;
}
bar.style.display = 'flex';
const nextVisualSignature = activeTasksRenderSignature(normalizedTasks);
if (
nextVisualSignature === activeTasksVisualSignature &&
bar.querySelectorAll('.active-task-item').length === normalizedTasks.length
) {
return;
}
const previousScrollLeft = bar.scrollLeft;
activeTasksVisualSignature = nextVisualSignature;
bar.innerHTML = '';
function openActiveTaskConversation(conversationId) {
@@ -7012,6 +7062,7 @@ function renderActiveTasks(tasks) {
bar.appendChild(item);
});
bar.scrollLeft = previousScrollLeft;
}
function reconcileHitlApprovalStateWithActiveTasks(tasks) {
+7 -5
View File
@@ -3352,15 +3352,17 @@ function appendChatProjectConversationItem(list, conversation, project) {
});
button.appendChild(label);
button.addEventListener('click', async () => {
button.addEventListener('click', async (event) => {
const targetConversationId = String(event.currentTarget && event.currentTarget.dataset.conversationId || '').trim();
if (!targetConversationId) return;
projectConversationPreviewSuppressedUntil = Date.now() + 700;
hideProjectConversationPreview(true);
selectChatProjectConversationItem(conversation.id);
selectChatProjectConversationItem(targetConversationId);
if (typeof window.loadConversation === 'function') {
await window.loadConversation(conversation.id);
await window.loadConversation(targetConversationId);
}
if (window.currentConversationId === conversation.id && completed) {
markProjectConversationViewed(conversation.id, completed.completedAt);
if (window.currentConversationId === targetConversationId && completed) {
markProjectConversationViewed(targetConversationId, completed.completedAt);
renderChatProjectFolders(projectsCacheAll);
}
});
+9
View File
@@ -18,6 +18,12 @@ function buildHashForPage(pageId) {
let chatConversationFromHashSeq = 0;
function cancelScheduledChatConversationFromHash() {
chatConversationFromHashSeq++;
setChatConversationRestorePending('', false);
}
window.cancelScheduledChatConversationFromHash = cancelScheduledChatConversationFromHash;
function setChatConversationRestorePending(conversationId, pending) {
const container = document.querySelector('.chat-container');
if (!container) return;
@@ -123,6 +129,9 @@ function switchPage(pageId) {
if (!targetPage) return;
if (pageId !== 'chat') {
setChatConversationRestorePending('', false);
if (currentPage === 'chat' && typeof window.abandonChatConversationForPageNavigation === 'function') {
window.abandonChatConversationForPageNavigation();
}
}
// 导航点击会修改 hash,随后浏览器还会触发 hashchange。
+3 -3
View File
@@ -35,7 +35,7 @@
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
<link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="/static/vendor/xterm.css">
<script src="/static/js/router.js?v=20260813-2"></script>
<script src="/static/js/router.js?v=20260819-3"></script>
</head>
<body>
<div id="login-overlay" class="login-overlay" style="display: none;">
@@ -6845,7 +6845,7 @@
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/chat-scroll.js?v=20260815-1"></script>
<script src="/static/js/monitor.js?v=20260819-3"></script>
<script src="/static/js/chat.js?v=20260819-1"></script>
<script src="/static/js/chat.js?v=20260819-5"></script>
<script src="/static/js/chat-plan-progress.js?v=20260815-1"></script>
<script src="/static/js/hitl.js?v=20260819-1"></script>
<script src="/static/js/settings.js?v=20260717-1"></script>
@@ -6858,7 +6858,7 @@
<script src="/static/js/knowledge.js"></script>
<script src="/static/js/skills.js"></script>
<script src="/static/js/fact-graph.js"></script>
<script src="/static/js/projects.js?v=20260812-6"></script>
<script src="/static/js/projects.js?v=20260819-1"></script>
<script src="/static/js/vulnerability.js?v=14"></script>
<script src="/static/js/webshell.js"></script>
<script src="/static/js/chat-files.js"></script>