修复多会话任务栏抖动、会话跳回与停止失效 (#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
+11 -1
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"context" "context"
"errors" "errors"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -484,7 +485,10 @@ func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool,
if runtimeCancel != nil { if runtimeCancel != nil {
runtimeHandled = runtimeCancel(cause) runtimeHandled = runtimeCancel(cause)
} }
if cancel != nil && !runtimeHandled { // 「彻底停止」必须同时取消宿主 context:原生 Agent Cancel 即使已受理,
// 也可能只在安全点返回或报告超时,不能据此让整条任务继续存活。
// 中断并继续仍保留原语义:原生取消已处理时由运行时负责恢复。
if cancel != nil && (!runtimeHandled || errors.Is(cause, ErrTaskCancelled)) {
cancel(cause) cancel(cause)
} }
if toolCanceler != nil { if toolCanceler != nil {
@@ -591,6 +595,12 @@ func (m *AgentTaskManager) GetActiveTasks() []*AgentTask {
Status: task.Status, 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 return result
} }
@@ -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])
}
}
}
}
@@ -32,7 +32,7 @@ func TestCancelTaskInvokesToolCancelerOnFullStop(t *testing.T) {
} }
} }
func TestCancelTaskUsesAgentRuntimeCancelAsPrimaryPath(t *testing.T) { func TestCancelTaskFullStopCancelsRuntimeAndParentContext(t *testing.T) {
tm := NewAgentTaskManager() tm := NewAgentTaskManager()
var order []string var order []string
tm.SetToolCanceler(func(conversationID string) { tm.SetToolCanceler(func(conversationID string) {
@@ -61,7 +61,7 @@ func TestCancelTaskUsesAgentRuntimeCancelAsPrimaryPath(t *testing.T) {
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("CancelTask: ok=%v err=%v", ok, err) t.Fatalf("CancelTask: ok=%v err=%v", ok, err)
} }
want := []string{"runtime", "tool"} want := []string{"runtime", "context", "tool"}
if len(order) != len(want) { if len(order) != len(want) {
t.Fatalf("order length got %d want %d: %#v", len(order), len(want), order) 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) { func TestCancelTaskFallsBackToContextWhenAgentRuntimeCancelMisses(t *testing.T) {
tm := NewAgentTaskManager() tm := NewAgentTaskManager()
var order []string var order []string
+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 scroll = fs.readFileSync('web/static/js/chat-scroll.js', 'utf8');
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8'); const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
const chat = fs.readFileSync('web/static/js/chat.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 router = fs.readFileSync('web/static/js/router.js', 'utf8');
const auth = fs.readFileSync('web/static/js/auth.js', 'utf8'); const auth = fs.readFileSync('web/static/js/auth.js', 'utf8');
const webshell = fs.readFileSync('web/static/js/webshell.js', 'utf8'); const webshell = fs.readFileSync('web/static/js/webshell.js', 'utf8');
@@ -383,6 +384,76 @@ test('任务计划进度事件在活跃任务列表变化和新任务开始时
assert.match(renderSource, /detail: \{ tasks: normalizedTasks \}/); 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('刷新指定对话时立即恢复且加载完成前不闪出无项目状态', () => { test('刷新指定对话时立即恢复且加载完成前不闪出无项目状态', () => {
const scheduleSource = functionSource(router, 'scheduleChatConversationFromHash', 'navigateToConversation'); const scheduleSource = functionSource(router, 'scheduleChatConversationFromHash', 'navigateToConversation');
const restoreStateSource = functionSource(router, 'setChatConversationRestorePending', 'finishChatConversationRestore'); const restoreStateSource = functionSource(router, 'setChatConversationRestorePending', 'finishChatConversationRestore');
@@ -397,8 +468,8 @@ test('刷新指定对话时立即恢复且加载完成前不闪出无项目状
assert.match(loadSource, /finally \{[\s\S]*?finishChatConversationRestore\(conversationId\)/); 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-messages/);
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-input-container/); assert.match(css, /\.chat-container\.is-conversation-restoring #chat-input-container/);
assert.match(html, /router\.js\?v=20260813-2/); assert.match(html, /router\.js\?v=20260819-3/);
assert.match(html, /chat\.js\?v=20260819-1/); assert.match(html, /chat\.js\?v=20260819-5/);
}); });
test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => { test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => {
+112 -13
View File
@@ -10,8 +10,46 @@ function syncChatConversationHash(conversationId) {
} }
} }
window.syncChatConversationHash = syncChatConversationHash; 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 loadConversationRequestSeq = 0;
let loadConversationAbortController = null; 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 缓存 * 轻量会话 LRU 缓存
@@ -1768,6 +1806,18 @@ function ownsLiveChatStream(liveStream) {
return !!liveStream && window.__csAgentLiveStream === 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) { function clearLiveChatStreamIfOwned(liveStream) {
if (!ownsLiveChatStream(liveStream)) return false; if (!ownsLiveChatStream(liveStream)) return false;
liveStream.active = false; liveStream.active = false;
@@ -2208,6 +2258,8 @@ async function sendMessage() {
const input = document.getElementById('chat-input'); const input = document.getElementById('chat-input');
let message = input.value.trim(); let message = input.value.trim();
const hasAttachments = chatAttachments && chatAttachments.length > 0; const hasAttachments = chatAttachments && chatAttachments.length > 0;
const requestConversationId = currentConversationId;
const requestNavigationSeq = chatConversationNavigationSeq;
if (!message && !hasAttachments) { if (!message && !hasAttachments) {
return; return;
@@ -2261,6 +2313,12 @@ async function sendMessage() {
message = CHAT_FILE_DEFAULT_PROMPT; message = CHAT_FILE_DEFAULT_PROMPT;
} }
// 发送前的任务状态/附件检查可能包含异步等待。若用户已主动切换会话,
// 保留当前页面,不再把这次尚未发出的请求写入新的可见对话。
if (requestNavigationSeq !== chatConversationNavigationSeq) {
return;
}
// 显示用户消息(含附件名,便于用户确认) // 显示用户消息(含附件名,便于用户确认)
const displayMessage = hasAttachments const displayMessage = hasAttachments
? message + '\n' + chatAttachments.map(a => '📎 ' + a.fileName).join('\n') ? message + '\n' + chatAttachments.map(a => '📎 ' + a.fileName).join('\n')
@@ -2296,7 +2354,7 @@ async function sendMessage() {
// 构建请求体(含附件) // 构建请求体(含附件)
const body = { const body = {
message: message, message: message,
conversationId: currentConversationId, conversationId: requestConversationId,
role: typeof getCurrentRole === 'function' ? getCurrentRole() : '' role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
}; };
if (window.__csNextChatFinalizationPolicy && typeof window.__csNextChatFinalizationPolicy === 'object') { if (window.__csNextChatFinalizationPolicy && typeof window.__csNextChatFinalizationPolicy === 'object') {
@@ -2357,7 +2415,8 @@ async function sendMessage() {
conversationId: streamConversationId || null, conversationId: streamConversationId || null,
progressId: progressId, progressId: progressId,
abortController: requestAbortController, abortController: requestAbortController,
detached: false detached: false,
navigationSeq: requestNavigationSeq
}; };
window.__csAgentLiveStream = liveStreamState; window.__csAgentLiveStream = liveStreamState;
if (streamConversationId && typeof window.notifyConversationTaskStarted === 'function') { if (streamConversationId && typeof window.notifyConversationTaskStarted === 'function') {
@@ -2408,18 +2467,18 @@ async function sendMessage() {
if (streamConversationId && streamConversationId !== eventConvId) { if (streamConversationId && streamConversationId !== eventConvId) {
return; return;
} }
if (!streamConversationId && eventData.type === 'conversation') { if (!streamConversationId) {
streamConversationId = eventConvId; streamConversationId = eventConvId;
liveStreamState.conversationId = eventConvId; liveStreamState.conversationId = eventConvId;
justBoundConversation = true; 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()) { if (!justBoundConversation && !isStreamStillVisibleForRequest()) {
return; return;
} }
@@ -5652,6 +5711,11 @@ async function startNewConversation(options = {}) {
const requestedProjectId = hasExplicitProjectId const requestedProjectId = hasExplicitProjectId
? String(options.projectId || '').trim() ? String(options.projectId || '').trim()
: String(inheritedProjectId || '').trim(); : String(inheritedProjectId || '').trim();
markChatConversationNavigation('', true);
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
window.cancelScheduledChatConversationFromHash();
}
clearChatConversationHash();
cancelPendingConversationLoad(); cancelPendingConversationLoad();
detachLiveChatStreamForNavigation('', true); detachLiveChatStreamForNavigation('', true);
if (typeof window.cancelRunningTaskEventStream === 'function') { if (typeof window.cancelRunningTaskEventStream === 'function') {
@@ -5776,7 +5840,8 @@ function createConversationListItem(conversation) {
item.onclick = (e) => { item.onclick = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
loadConversation(conversation.id); const targetConversationId = String(item.dataset.conversationId || '').trim();
if (targetConversationId) loadConversation(targetConversationId);
}; };
return item; return item;
} }
@@ -6092,16 +6157,30 @@ async function prefetchLastAssistantProcessDetails() {
} }
async function loadConversation(conversationId) { async function loadConversation(conversationId) {
conversationId = String(conversationId || '').trim();
if (!conversationId) return;
// Keep the visible conversation addressable across a full page refresh. // Keep the visible conversation addressable across a full page refresh.
// Sidebar/project entries call loadConversation directly (rather than the // Sidebar/project entries call loadConversation directly (rather than the
// router helper), so without this synchronization #chat loses the active // router helper), so without this synchronization #chat loses the active
// conversation and reload falls back to the welcome screen instead of // conversation and reload falls back to the welcome screen instead of
// reconnecting the running task event stream. // reconnecting the running task event stream.
markChatConversationNavigation(conversationId);
if (typeof window.cancelScheduledChatConversationFromHash === 'function') {
window.cancelScheduledChatConversationFromHash();
}
syncChatConversationHash(conversationId); syncChatConversationHash(conversationId);
const seq = ++loadConversationRequestSeq; const seq = ++loadConversationRequestSeq;
const previousConversationId = currentConversationId; const previousConversationId = currentConversationId;
cancelPendingConversationLoad(); cancelPendingConversationLoad();
detachLiveChatStreamForNavigation(conversationId); detachLiveChatStreamForNavigation(conversationId);
// 用户单击即代表新的可见会话。必须在任何网络等待之前提交该选择,
// 否则每 2 秒的活跃任务刷新仍会把旧会话识别为可见,并排队重载旧补流,
// 反过来取消这次切换。
currentConversationId = conversationId;
try {
window.currentConversationId = conversationId;
} catch (e) { /* ignore */ }
loadConversationPendingId = conversationId;
const conversationLoadController = new AbortController(); const conversationLoadController = new AbortController();
loadConversationAbortController = conversationLoadController; loadConversationAbortController = conversationLoadController;
if (typeof window.selectChatProjectConversationItem === 'function') { if (typeof window.selectChatProjectConversationItem === 'function') {
@@ -6132,6 +6211,14 @@ async function loadConversation(conversationId) {
return; return;
} }
if (response && !response.ok) { 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'); showChatToast('加载对话失败: ' + (conversation.error || '未知错误'), 'error');
return; return;
} }
@@ -6418,8 +6505,16 @@ async function loadConversation(conversationId) {
} }
} catch (error) { } catch (error) {
if (error && error.name === 'AbortError') return; if (error && error.name === 'AbortError') return;
if (seq === loadConversationRequestSeq && typeof window.selectChatProjectConversationItem === 'function') { if (seq === loadConversationRequestSeq) {
window.selectChatProjectConversationItem(previousConversationId); 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); console.error('加载对话失败:', error);
showChatToast('加载对话失败: ' + (error && error.message ? error.message : String(error)), 'error'); showChatToast('加载对话失败: ' + (error && error.message ? error.message : String(error)), 'error');
@@ -6430,6 +6525,9 @@ async function loadConversation(conversationId) {
if (loadConversationAbortController === conversationLoadController) { if (loadConversationAbortController === conversationLoadController) {
loadConversationAbortController = null; loadConversationAbortController = null;
} }
if (seq === loadConversationRequestSeq && loadConversationPendingId === conversationId) {
loadConversationPendingId = '';
}
} }
} }
@@ -10093,7 +10191,8 @@ function createConversationListItemWithMenu(conversation, isPinned) {
if (currentGroupId) { if (currentGroupId) {
exitGroupDetail(); exitGroupDetail();
} }
loadConversation(conversation.id); const targetConversationId = String(item.dataset.conversationId || '').trim();
if (targetConversationId) loadConversation(targetConversationId);
}; };
return item; 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, /liveStream\.detached = true;[\s\S]{0,240}controller\.abort\(\)/);
assert.match(chat, /const requestAbortController = new AbortController\(\)/); assert.match(chat, /const requestAbortController = new AbortController\(\)/);
assert.match(chat, /signal: requestAbortController\.signal/); 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, /const clearedOwnedStream = clearLiveChatStreamIfOwned\(liveStreamState\)/);
assert.match(chat, /detachLiveChatStreamForNavigation\(conversationId\)/); assert.match(chat, /detachLiveChatStreamForNavigation\(conversationId\)/);
assert.match(chat, /detachLiveChatStreamForNavigation\('', true\)/); assert.match(chat, /detachLiveChatStreamForNavigation\('', true\)/);
@@ -289,14 +289,26 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
assert.match(monitor, /function scrollProcessDetailsToLatest\(assistantMessageId, smooth = true\)/); assert.match(monitor, /function scrollProcessDetailsToLatest\(assistantMessageId, smooth = true\)/);
assert.match(monitor, /timeline\.scrollTop = targetTop/); assert.match(monitor, /timeline\.scrollTop = targetTop/);
assert.match(chat, /let loadConversationAbortController = null/); 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(chat, /signal: conversationLoadController\.signal/);
assert.match(template, /monitor\.js\?v=20260819-3/); assert.match(template, /monitor\.js\?v=20260819-3/);
assert.match(template, /chat-scroll\.js\?v=20260815-1/); 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/); 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 审查文字保留足够行高且不会裁切字形', () => { test('输入区 Agent 审查文字保留足够行高且不会裁切字形', () => {
assert.match(styles, /\.chat-hitl-shortcut > span\s*\{[\s\S]*?display: block/); 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/); 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(monitor, /renderActiveTasks\(\[\]\);[\s\S]{0,260}hitlPendingInterruptTracker\.update\(\[\]\)/);
assert.match(projects, /function syncProjectConversationApprovalStatuses\(items\)/); assert.match(projects, /function syncProjectConversationApprovalStatuses\(items\)/);
assert.match(projects, /window\.syncProjectConversationApprovalStatuses/); assert.match(projects, /window\.syncProjectConversationApprovalStatuses/);
assert.match(template, /projects\.js\?v=20260812-6/); assert.match(template, /projects\.js\?v=20260819-1/);
}); });
test('旧会话首次升级到五分钟默认审批时限,仍允许用户之后主动选择不限时', () => { 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 TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
const hitlInterruptToolItemMap = new Map(); const hitlInterruptToolItemMap = new Map();
let activeTasksLoadPromise = null; let activeTasksLoadPromise = null;
let activeTasksVisualSignature = '';
const CHAT_TASK_SYNC_CHANNEL_NAME = 'cyberstrike-chat-task-sync-v1'; const CHAT_TASK_SYNC_CHANNEL_NAME = 'cyberstrike-chat-task-sync-v1';
let chatTaskSyncChannel = null; let chatTaskSyncChannel = null;
let visibleConversationReplaySyncPromise = null; let visibleConversationReplaySyncPromise = null;
@@ -1432,7 +1433,7 @@ async function submitUserInterruptHardCancel() {
const { progressId, conversationId } = userInterruptModalPending; const { progressId, conversationId } = userInterruptModalPending;
closeUserInterruptModal(); closeUserInterruptModal();
if (progressId) { if (progressId) {
await performHardCancelProgressTask(progressId); await performHardCancelProgressTask(progressId, conversationId);
return; return;
} }
if (!conversationId) { if (!conversationId) {
@@ -1448,11 +1449,12 @@ async function submitUserInterruptHardCancel() {
} }
/** 彻底停止任务(原「停止任务」行为) */ /** 彻底停止任务(原「停止任务」行为) */
async function performHardCancelProgressTask(progressId) { async function performHardCancelProgressTask(progressId, conversationId = '') {
const state = progressTaskState.get(progressId); const state = progressTaskState.get(progressId);
const stopBtn = document.getElementById(`${progressId}-stop-btn`); const stopBtn = document.getElementById(`${progressId}-stop-btn`);
const targetConversationId = String(conversationId || (state && state.conversationId) || '').trim();
if (!state || !state.conversationId) { if (!targetConversationId) {
if (stopBtn) { if (stopBtn) {
stopBtn.disabled = true; stopBtn.disabled = true;
setTimeout(() => { setTimeout(() => {
@@ -1463,7 +1465,7 @@ async function performHardCancelProgressTask(progressId) {
return; return;
} }
if (state.cancelling) { if (state && state.cancelling) {
return; return;
} }
@@ -1474,7 +1476,7 @@ async function performHardCancelProgressTask(progressId) {
} }
try { try {
await requestCancel(state.conversationId); await requestCancel(targetConversationId);
loadActiveTasks(); loadActiveTasks();
} catch (error) { } catch (error) {
console.error('取消任务失败:', error); console.error('取消任务失败:', error);
@@ -6858,6 +6860,17 @@ function syncVisibleConversationTaskReplay(tasks) {
visibleConversationReplaySyncId = conversationId; visibleConversationReplaySyncId = conversationId;
visibleConversationReplaySyncPromise = Promise.resolve() visibleConversationReplaySyncPromise = Promise.resolve()
.then(async function () { .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') { if (typeof window.loadConversation === 'function') {
await window.loadConversation(conversationId); await window.loadConversation(conversationId);
@@ -6893,6 +6906,33 @@ function getActiveTaskDisplayName(task) {
return message || unnamedTaskText; 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) { function updateActiveTaskConversationTitle(conversationId, newTitle) {
const bar = document.getElementById('active-tasks-bar'); const bar = document.getElementById('active-tasks-bar');
if (!bar || !conversationId) return; if (!bar || !conversationId) return;
@@ -6909,7 +6949,7 @@ function renderActiveTasks(tasks) {
const bar = document.getElementById('active-tasks-bar'); const bar = document.getElementById('active-tasks-bar');
if (!bar) return; if (!bar) return;
const normalizedTasks = Array.isArray(tasks) ? tasks : []; const normalizedTasks = stableActiveTasksForDisplay(tasks);
conversationExecutionTracker.update(normalizedTasks); conversationExecutionTracker.update(normalizedTasks);
window.dispatchEvent(new CustomEvent('conversation-task-state-changed', { window.dispatchEvent(new CustomEvent('conversation-task-state-changed', {
detail: { tasks: normalizedTasks } detail: { tasks: normalizedTasks }
@@ -6930,10 +6970,20 @@ function renderActiveTasks(tasks) {
if (normalizedTasks.length === 0) { if (normalizedTasks.length === 0) {
bar.style.display = 'none'; bar.style.display = 'none';
bar.innerHTML = ''; bar.innerHTML = '';
activeTasksVisualSignature = '';
return; return;
} }
bar.style.display = 'flex'; 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 = ''; bar.innerHTML = '';
function openActiveTaskConversation(conversationId) { function openActiveTaskConversation(conversationId) {
@@ -7012,6 +7062,7 @@ function renderActiveTasks(tasks) {
bar.appendChild(item); bar.appendChild(item);
}); });
bar.scrollLeft = previousScrollLeft;
} }
function reconcileHitlApprovalStateWithActiveTasks(tasks) { function reconcileHitlApprovalStateWithActiveTasks(tasks) {
+7 -5
View File
@@ -3352,15 +3352,17 @@ function appendChatProjectConversationItem(list, conversation, project) {
}); });
button.appendChild(label); 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; projectConversationPreviewSuppressedUntil = Date.now() + 700;
hideProjectConversationPreview(true); hideProjectConversationPreview(true);
selectChatProjectConversationItem(conversation.id); selectChatProjectConversationItem(targetConversationId);
if (typeof window.loadConversation === 'function') { if (typeof window.loadConversation === 'function') {
await window.loadConversation(conversation.id); await window.loadConversation(targetConversationId);
} }
if (window.currentConversationId === conversation.id && completed) { if (window.currentConversationId === targetConversationId && completed) {
markProjectConversationViewed(conversation.id, completed.completedAt); markProjectConversationViewed(targetConversationId, completed.completedAt);
renderChatProjectFolders(projectsCacheAll); renderChatProjectFolders(projectsCacheAll);
} }
}); });
+9
View File
@@ -18,6 +18,12 @@ function buildHashForPage(pageId) {
let chatConversationFromHashSeq = 0; let chatConversationFromHashSeq = 0;
function cancelScheduledChatConversationFromHash() {
chatConversationFromHashSeq++;
setChatConversationRestorePending('', false);
}
window.cancelScheduledChatConversationFromHash = cancelScheduledChatConversationFromHash;
function setChatConversationRestorePending(conversationId, pending) { function setChatConversationRestorePending(conversationId, pending) {
const container = document.querySelector('.chat-container'); const container = document.querySelector('.chat-container');
if (!container) return; if (!container) return;
@@ -123,6 +129,9 @@ function switchPage(pageId) {
if (!targetPage) return; if (!targetPage) return;
if (pageId !== 'chat') { if (pageId !== 'chat') {
setChatConversationRestorePending('', false); setChatConversationRestorePending('', false);
if (currentPage === 'chat' && typeof window.abandonChatConversationForPageNavigation === 'function') {
window.abandonChatConversationForPageNavigation();
}
} }
// 导航点击会修改 hash,随后浏览器还会触发 hashchange。 // 导航点击会修改 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/chat-plan-progress.css?v=20260813-4">
<link rel="stylesheet" href="/static/css/c2.css"> <link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="/static/vendor/xterm.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> </head>
<body> <body>
<div id="login-overlay" class="login-overlay" style="display: none;"> <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/dashboard.js"></script>
<script src="/static/js/chat-scroll.js?v=20260815-1"></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/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/chat-plan-progress.js?v=20260815-1"></script>
<script src="/static/js/hitl.js?v=20260819-1"></script> <script src="/static/js/hitl.js?v=20260819-1"></script>
<script src="/static/js/settings.js?v=20260717-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/knowledge.js"></script>
<script src="/static/js/skills.js"></script> <script src="/static/js/skills.js"></script>
<script src="/static/js/fact-graph.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/vulnerability.js?v=14"></script>
<script src="/static/js/webshell.js"></script> <script src="/static/js/webshell.js"></script>
<script src="/static/js/chat-files.js"></script> <script src="/static/js/chat-files.js"></script>