diff --git a/internal/handler/task_manager.go b/internal/handler/task_manager.go index 1a55789e..24d35b40 100644 --- a/internal/handler/task_manager.go +++ b/internal/handler/task_manager.go @@ -3,6 +3,7 @@ package handler import ( "context" "errors" + "sort" "strings" "sync" "time" @@ -484,7 +485,10 @@ func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool, if runtimeCancel != nil { runtimeHandled = runtimeCancel(cause) } - if cancel != nil && !runtimeHandled { + // 「彻底停止」必须同时取消宿主 context:原生 Agent Cancel 即使已受理, + // 也可能只在安全点返回或报告超时,不能据此让整条任务继续存活。 + // 中断并继续仍保留原语义:原生取消已处理时由运行时负责恢复。 + if cancel != nil && (!runtimeHandled || errors.Is(cause, ErrTaskCancelled)) { cancel(cause) } if toolCanceler != nil { @@ -591,6 +595,12 @@ func (m *AgentTaskManager) GetActiveTasks() []*AgentTask { Status: task.Status, }) } + sort.Slice(result, func(i, j int) bool { + if result[i].StartedAt.Equal(result[j].StartedAt) { + return result[i].ConversationID < result[j].ConversationID + } + return result[i].StartedAt.Before(result[j].StartedAt) + }) return result } diff --git a/internal/handler/task_manager_order_test.go b/internal/handler/task_manager_order_test.go new file mode 100644 index 00000000..78b8fba4 --- /dev/null +++ b/internal/handler/task_manager_order_test.go @@ -0,0 +1,31 @@ +package handler + +import ( + "testing" + "time" +) + +func TestGetActiveTasksUsesStableCreationOrder(t *testing.T) { + m := NewAgentTaskManager() + started := time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC) + m.mu.Lock() + m.tasks = map[string]*AgentTask{ + "conversation-z": {ConversationID: "conversation-z", StartedAt: started, Status: "running"}, + "conversation-late": {ConversationID: "conversation-late", StartedAt: started.Add(time.Minute), Status: "running"}, + "conversation-a": {ConversationID: "conversation-a", StartedAt: started, Status: "running"}, + } + m.mu.Unlock() + + want := []string{"conversation-a", "conversation-z", "conversation-late"} + for attempt := 0; attempt < 20; attempt++ { + gotTasks := m.GetActiveTasks() + if len(gotTasks) != len(want) { + t.Fatalf("GetActiveTasks() length = %d, want %d", len(gotTasks), len(want)) + } + for i, task := range gotTasks { + if task.ConversationID != want[i] { + t.Fatalf("attempt %d order[%d] = %q, want %q", attempt, i, task.ConversationID, want[i]) + } + } + } +} diff --git a/internal/handler/task_manager_tool_cancel_test.go b/internal/handler/task_manager_tool_cancel_test.go index 51b411b0..df1bd3b8 100644 --- a/internal/handler/task_manager_tool_cancel_test.go +++ b/internal/handler/task_manager_tool_cancel_test.go @@ -32,7 +32,7 @@ func TestCancelTaskInvokesToolCancelerOnFullStop(t *testing.T) { } } -func TestCancelTaskUsesAgentRuntimeCancelAsPrimaryPath(t *testing.T) { +func TestCancelTaskFullStopCancelsRuntimeAndParentContext(t *testing.T) { tm := NewAgentTaskManager() var order []string tm.SetToolCanceler(func(conversationID string) { @@ -61,7 +61,7 @@ func TestCancelTaskUsesAgentRuntimeCancelAsPrimaryPath(t *testing.T) { if err != nil || !ok { t.Fatalf("CancelTask: ok=%v err=%v", ok, err) } - want := []string{"runtime", "tool"} + want := []string{"runtime", "context", "tool"} if len(order) != len(want) { t.Fatalf("order length got %d want %d: %#v", len(order), len(want), order) } @@ -72,6 +72,29 @@ func TestCancelTaskUsesAgentRuntimeCancelAsPrimaryPath(t *testing.T) { } } +func TestCancelTaskInterruptContinueKeepsParentWhenRuntimeHandlesIt(t *testing.T) { + tm := NewAgentTaskManager() + ctx, cancel := context.WithCancelCause(context.Background()) + if _, err := tm.StartTask("conv-interrupt-native", "hello", cancel); err != nil { + t.Fatalf("StartTask: %v", err) + } + unregister := tm.BindAgentRuntimeCancel("conv-interrupt-native", func(err error) bool { + if !errors.Is(err, multiagent.ErrInterruptContinue) { + t.Fatalf("runtime cancel got %v", err) + } + return true + }) + defer unregister() + + ok, err := tm.CancelTask("conv-interrupt-native", multiagent.ErrInterruptContinue) + if err != nil || !ok { + t.Fatalf("CancelTask: ok=%v err=%v", ok, err) + } + if cause := context.Cause(ctx); cause != nil { + t.Fatalf("interrupt-continue parent context cause = %v, want nil", cause) + } +} + func TestCancelTaskFallsBackToContextWhenAgentRuntimeCancelMisses(t *testing.T) { tm := NewAgentTaskManager() var order []string diff --git a/web/static/js/chat-scroll-refresh.test.cjs b/web/static/js/chat-scroll-refresh.test.cjs index d0f24e37..9c83ba68 100644 --- a/web/static/js/chat-scroll-refresh.test.cjs +++ b/web/static/js/chat-scroll-refresh.test.cjs @@ -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 并继续追加未来增量', () => { diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 565b1ae2..9998c24c 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -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; diff --git a/web/static/js/hitl-approval-ui.test.cjs b/web/static/js/hitl-approval-ui.test.cjs index c540b102..2366727f 100644 --- a/web/static/js/hitl-approval-ui.test.cjs +++ b/web/static/js/hitl-approval-ui.test.cjs @@ -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('旧会话首次升级到五分钟默认审批时限,仍允许用户之后主动选择不限时', () => { diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index b84af16f..16422874 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -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) { diff --git a/web/static/js/projects.js b/web/static/js/projects.js index af5cbc21..e2b0e92a 100644 --- a/web/static/js/projects.js +++ b/web/static/js/projects.js @@ -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); } }); diff --git a/web/static/js/router.js b/web/static/js/router.js index e120d972..b323f118 100644 --- a/web/static/js/router.js +++ b/web/static/js/router.js @@ -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。 diff --git a/web/templates/index.html b/web/templates/index.html index 86545065..c094de23 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -35,7 +35,7 @@ - +