mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-23 19:32:39 +02:00
修复多会话任务栏抖动、会话跳回与停止失效 (#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:
@@ -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
|
||||||
|
|||||||
@@ -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 并继续追加未来增量', () => {
|
||||||
|
|||||||
+111
-12
@@ -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) {
|
// 切换对话后仍可能收到旧响应流中已缓冲的 conversation、response_start
|
||||||
updateProgressConversation(progressId, eventConvId);
|
// 或 response 事件。它们只能补齐后台任务归属,不能重新抢占当前对话。
|
||||||
|
if (shouldIgnoreLiveChatStreamEvent(liveStreamState)) {
|
||||||
|
if (eventConvId) updateProgressConversation(progressId, eventConvId);
|
||||||
return;
|
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,9 +6505,17 @@ 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) {
|
||||||
|
currentConversationId = previousConversationId;
|
||||||
|
try {
|
||||||
|
window.currentConversationId = previousConversationId || '';
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
if (previousConversationId) syncChatConversationHash(previousConversationId);
|
||||||
|
else clearChatConversationHash();
|
||||||
|
if (typeof window.selectChatProjectConversationItem === 'function') {
|
||||||
window.selectChatProjectConversationItem(previousConversationId);
|
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');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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('旧会话首次升级到五分钟默认审批时限,仍允许用户之后主动选择不限时', () => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user