修复多会话任务栏抖动、会话跳回与停止失效 (#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
+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;