Commit 9b1c3f148315835e1c8a47e95f251ffbfffefa0b

Authored by yibo
1 parent d48457ac

fix(agent-chat): 聊天状态按 agent 隔离,切换不再串台/互相阻塞

AgentChat.vue 之前把 sending/streaming/streamContent/thinkingText/
thinkingContent/subAgents/acpLog/messages/activeConvId 全部做成页面级单例 ref,
不区分是哪个 agent 的。切 agent 时既不取消上一个还在跑的读流循环,也不重置这些
状态,导致三个连环症状:①思考中/等待回复横幅带着切过去;②只要有任意一个 agent
在跑,所有 agent 的输入框都被同一个 sending 锁成"等待回复中";③A 流式输出时切去
看 B,A 的内容被当成 B 的渲染,A 说完那句话直接被 push 进 B 的消息列表。

改为按 agentId 存一份 reactive Map(chatStates),模板读"当前选中 agent"那一份
slice;handleSend/handleEvent 认发起时捕获的 requestAgentId 写自己的 slice,不认
"用户现在选中的是谁"。switchAgent 不再无条件清空目标 agent 状态——只有它空闲时
才去刷新/定位会话,正在生成就原样展示缓存,回切能看到完整后台进度。真机验证:
向 Master 发消息流式生成期间切到另一个 agent,输入框未被锁定、无状态污染,切
回 Master 能看到完整正确的输出。

顺带把已经在这个文件里的一处未提交小改动(agent 列表排序:Master 置顶)一并
收进这次提交,不是本次改动的一部分。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing 1 changed file with 161 additions and 125 deletions
web/src/views/AgentChat.vue
@@ -58,7 +58,7 @@ @@ -58,7 +58,7 @@
58 v-for="conv in filteredConversations" 58 v-for="conv in filteredConversations"
59 :key="conv.conversationId || conv.id" 59 :key="conv.conversationId || conv.id"
60 class="conv-card" 60 class="conv-card"
61 - :class="{ active: activeConvId === (conv.conversationId || conv.id) }" 61 + :class="{ active: chatState.activeConvId === (conv.conversationId || conv.id) }"
62 @click="switchConversation(conv.conversationId || conv.id)" 62 @click="switchConversation(conv.conversationId || conv.id)"
63 > 63 >
64 <div class="conv-card-top"> 64 <div class="conv-card-top">
@@ -86,7 +86,7 @@ @@ -86,7 +86,7 @@
86 86
87 <!-- 第3列: 消息区 --> 87 <!-- 第3列: 消息区 -->
88 <section class="chat-panel"> 88 <section class="chat-panel">
89 - <div v-if="messages.length === 0 && !streaming" class="welcome-screen"> 89 + <div v-if="chatState.messages.length === 0 && !chatState.streaming" class="welcome-screen">
90 <div class="welcome-content"> 90 <div class="welcome-content">
91 <div class="welcome-emoji">{{ agentInfo.emoji || '🏗️' }}</div> 91 <div class="welcome-emoji">{{ agentInfo.emoji || '🏗️' }}</div>
92 <h2 class="welcome-title">{{ agentInfo.name || 'Agent' }}</h2> 92 <h2 class="welcome-title">{{ agentInfo.name || 'Agent' }}</h2>
@@ -99,7 +99,7 @@ @@ -99,7 +99,7 @@
99 </div> 99 </div>
100 </div> 100 </div>
101 <div v-else ref="messageListRef" class="message-list"> 101 <div v-else ref="messageListRef" class="message-list">
102 - <div v-for="(msg, idx) in messages" :key="idx" class="msg-row" :class="`msg-${msg.role}`"> 102 + <div v-for="(msg, idx) in chatState.messages" :key="idx" class="msg-row" :class="`msg-${msg.role}`">
103 <div class="msg-avatar" :class="`avatar-${msg.role}`">{{ msg.role === 'user' ? '👤' : (agentInfo.emoji || '🏗️') }}</div> 103 <div class="msg-avatar" :class="`avatar-${msg.role}`">{{ msg.role === 'user' ? '👤' : (agentInfo.emoji || '🏗️') }}</div>
104 <div class="msg-body"> 104 <div class="msg-body">
105 <div class="msg-header"> 105 <div class="msg-header">
@@ -117,24 +117,24 @@ @@ -117,24 +117,24 @@
117 </div> 117 </div>
118 </div> 118 </div>
119 </div> 119 </div>
120 - <div v-if="streaming" class="msg-row msg-assistant streaming"> 120 + <div v-if="chatState.streaming" class="msg-row msg-assistant streaming">
121 <div class="msg-avatar avatar-assistant">{{ agentInfo.emoji || '🏗️' }}</div> 121 <div class="msg-avatar avatar-assistant">{{ agentInfo.emoji || '🏗️' }}</div>
122 <div class="msg-body"> 122 <div class="msg-body">
123 <div class="msg-header"><span class="msg-role">{{ agentInfo.name }}</span><span class="typing-indicator"><span></span><span></span><span></span></span></div> 123 <div class="msg-header"><span class="msg-role">{{ agentInfo.name }}</span><span class="typing-indicator"><span></span><span></span><span></span></span></div>
124 - <div class="msg-content" v-html="renderMarkdown(streamContent)"></div> 124 + <div class="msg-content" v-html="renderMarkdown(chatState.streamContent)"></div>
125 </div> 125 </div>
126 </div> 126 </div>
127 - <div v-if="sending && thinkingContent" class="thinking-block">  
128 - <div class="thinking-header" @click="showThinking = !showThinking"> 127 + <div v-if="chatState.sending && chatState.thinkingContent" class="thinking-block">
  128 + <div class="thinking-header" @click="chatState.showThinking = !chatState.showThinking">
129 <div class="thinking-pulse"></div> 129 <div class="thinking-pulse"></div>
130 - <span class="thinking-label">{{ streaming ? '💭 思考完成' : '💭 思考中...' }}</span>  
131 - <span class="thinking-toggle">{{ showThinking ? '▼' : '▶' }}</span> 130 + <span class="thinking-label">{{ chatState.streaming ? '💭 思考完成' : '💭 思考中...' }}</span>
  131 + <span class="thinking-toggle">{{ chatState.showThinking ? '▼' : '▶' }}</span>
132 </div> 132 </div>
133 - <div v-if="showThinking" class="thinking-content">{{ thinkingContent }}</div> 133 + <div v-if="chatState.showThinking" class="thinking-content">{{ chatState.thinkingContent }}</div>
134 </div> 134 </div>
135 - <div v-if="sending && !streaming && !thinkingContent" class="thinking-row"> 135 + <div v-if="chatState.sending && !chatState.streaming && !chatState.thinkingContent" class="thinking-row">
136 <div class="thinking-pulse"></div> 136 <div class="thinking-pulse"></div>
137 - <span class="thinking-text">{{ thinkingText }}</span> 137 + <span class="thinking-text">{{ chatState.thinkingText }}</span>
138 </div> 138 </div>
139 </div> 139 </div>
140 <div class="chat-input-area" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="handleDrop"> 140 <div class="chat-input-area" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="handleDrop">
@@ -160,28 +160,28 @@ @@ -160,28 +160,28 @@
160 <div class="input-box" :class="{ focused: inputFocused }"> 160 <div class="input-box" :class="{ focused: inputFocused }">
161 <button class="attach-btn" @click="triggerFileInput" title="上传文件">📎</button> 161 <button class="attach-btn" @click="triggerFileInput" title="上传文件">📎</button>
162 <input type="file" ref="fileInputRef" multiple style="display:none" @change="handleFileSelect" /> 162 <input type="file" ref="fileInputRef" multiple style="display:none" @change="handleFileSelect" />
163 - <textarea ref="inputRef" v-model="inputText" :disabled="sending" :placeholder="sending ? '等待回复中...' : '跟 ' + agentInfo.name + ' 对话...'" class="msg-textarea" rows="1" @keydown="handleKeydown" @focus="inputFocused = true" @blur="inputFocused = false" @input="autoResize" @paste="handlePaste"></textarea>  
164 - <button v-if="sending" class="send-btn stop-btn" @click="handleStop">⏹</button>  
165 - <button v-else class="send-btn" :class="{ ready: (inputText.trim() || pendingFiles.length) && !sending }" :disabled="!inputText.trim() && !pendingFiles.length" @click="handleSend">➤</button> 163 + <textarea ref="inputRef" v-model="inputText" :disabled="chatState.sending" :placeholder="chatState.sending ? '等待回复中...' : '跟 ' + agentInfo.name + ' 对话...'" class="msg-textarea" rows="1" @keydown="handleKeydown" @focus="inputFocused = true" @blur="inputFocused = false" @input="autoResize" @paste="handlePaste"></textarea>
  164 + <button v-if="chatState.sending" class="send-btn stop-btn" @click="handleStop">⏹</button>
  165 + <button v-else class="send-btn" :class="{ ready: (inputText.trim() || pendingFiles.length) && !chatState.sending }" :disabled="!inputText.trim() && !pendingFiles.length" @click="handleSend">➤</button>
166 </div> 166 </div>
167 - <div class="input-hint">Shift+Enter 换行 · {{ messages.length }} 条消息<template v-if="pendingFiles.length"> · {{ pendingFiles.length }} 个待上传文件</template><template v-if="uploadedFiles.length"> · 📎 {{ uploadedFiles.length }} 个附件</template></div> 167 + <div class="input-hint">Shift+Enter 换行 · {{ chatState.messages.length }} 条消息<template v-if="pendingFiles.length"> · {{ pendingFiles.length }} 个待上传文件</template><template v-if="uploadedFiles.length"> · 📎 {{ uploadedFiles.length }} 个附件</template></div>
168 </div> 168 </div>
169 </section> 169 </section>
170 170
171 <!-- 第4列: ACP 事件 --> 171 <!-- 第4列: ACP 事件 -->
172 <aside class="acp-panel"> 172 <aside class="acp-panel">
173 - <div class="col-header"><span>⚡ ACP Events</span><span class="acp-count">{{ acpLog.length }}</span></div> 173 + <div class="col-header"><span>⚡ ACP Events</span><span class="acp-count">{{ chatState.acpLog.length }}</span></div>
174 <div class="acp-list"> 174 <div class="acp-list">
175 - <div v-if="acpLog.length > 0" class="acp-card">  
176 - <div class="acp-card-header"><span>⚡ Event Stream</span><span v-if="sending" class="badge-live">LIVE</span></div> 175 + <div v-if="chatState.acpLog.length > 0" class="acp-card">
  176 + <div class="acp-card-header"><span>⚡ Event Stream</span><span v-if="chatState.sending" class="badge-live">LIVE</span></div>
177 <div class="acp-log-stream"> 177 <div class="acp-log-stream">
178 - <div v-for="(evt, i) in acpLog" :key="i" class="acp-event-line" :class="'acp-' + evt.type"> 178 + <div v-for="(evt, i) in chatState.acpLog" :key="i" class="acp-event-line" :class="'acp-' + evt.type">
179 <span class="acp-icon">{{ evt.icon }}</span> 179 <span class="acp-icon">{{ evt.icon }}</span>
180 <span class="acp-text">{{ evt.text }}</span> 180 <span class="acp-text">{{ evt.text }}</span>
181 </div> 181 </div>
182 </div> 182 </div>
183 </div> 183 </div>
184 - <div v-for="(sub, callId) in subAgents" :key="'live-'+callId" class="sub-log-card" :class="sub.status"> 184 + <div v-for="(sub, callId) in chatState.subAgents" :key="'live-'+callId" class="sub-log-card" :class="sub.status">
185 <div class="sub-log-card-header" @click="sub.collapsed = !sub.collapsed"> 185 <div class="sub-log-card-header" @click="sub.collapsed = !sub.collapsed">
186 <span class="sub-log-icon">{{ sub.status === 'running' ? '🚀' : (sub.status === 'success' ? '✅' : '❌') }}</span> 186 <span class="sub-log-icon">{{ sub.status === 'running' ? '🚀' : (sub.status === 'success' ? '✅' : '❌') }}</span>
187 <span class="sub-log-agent">{{ sub.agent }}</span> 187 <span class="sub-log-agent">{{ sub.agent }}</span>
@@ -190,7 +190,7 @@ @@ -190,7 +190,7 @@
190 </div> 190 </div>
191 <div v-if="!sub.collapsed" class="sub-log-content terminal">{{ sub.content || '等待输出...' }}</div> 191 <div v-if="!sub.collapsed" class="sub-log-content terminal">{{ sub.content || '等待输出...' }}</div>
192 </div> 192 </div>
193 - <div v-for="log in subLogList" :key="log.file" class="sub-log-card" :class="log.status"> 193 + <div v-for="log in chatState.subLogList" :key="log.file" class="sub-log-card" :class="log.status">
194 <div class="sub-log-card-header" @click="log.collapsed = !log.collapsed"> 194 <div class="sub-log-card-header" @click="log.collapsed = !log.collapsed">
195 <span class="sub-log-icon">{{ log.status === 'success' ? '✅' : (log.status === 'error' ? '❌' : '⏳') }}</span> 195 <span class="sub-log-icon">{{ log.status === 'success' ? '✅' : (log.status === 'error' ? '❌' : '⏳') }}</span>
196 <span class="sub-log-agent">{{ log.agent }}</span> 196 <span class="sub-log-agent">{{ log.agent }}</span>
@@ -198,7 +198,7 @@ @@ -198,7 +198,7 @@
198 </div> 198 </div>
199 <div v-if="!log.collapsed" class="sub-log-content terminal">{{ log.content }}</div> 199 <div v-if="!log.collapsed" class="sub-log-content terminal">{{ log.content }}</div>
200 </div> 200 </div>
201 - <div v-if="acpLog.length === 0 && subLogList.length === 0 && Object.keys(subAgents).length === 0" class="acp-empty"> 201 + <div v-if="chatState.acpLog.length === 0 && chatState.subLogList.length === 0 && Object.keys(chatState.subAgents).length === 0" class="acp-empty">
202 发送消息后,ACP 事件<br>将在这里实时显示 202 发送消息后,ACP 事件<br>将在这里实时显示
203 </div> 203 </div>
204 </div> 204 </div>
@@ -326,7 +326,7 @@ @@ -326,7 +326,7 @@
326 </template> 326 </template>
327 327
328 <script setup> 328 <script setup>
329 -import { ref, computed, nextTick, watch, onMounted } from 'vue' 329 +import { ref, reactive, computed, nextTick, watch, onMounted } from 'vue'
330 import { useRoute, useRouter } from 'vue-router' 330 import { useRoute, useRouter } from 'vue-router'
331 import AppNav from '@/components/AppNav.vue' 331 import AppNav from '@/components/AppNav.vue'
332 import FileNode from '@/components/FileNode.vue' 332 import FileNode from '@/components/FileNode.vue'
@@ -356,9 +356,11 @@ const groupedAgents = computed(() =&gt; { @@ -356,9 +356,11 @@ const groupedAgents = computed(() =&gt; {
356 groups[comp].push(a) 356 groups[comp].push(a)
357 }) 357 })
358 358
359 - // Sort each group by lastActiveAt descending 359 + // Master agent 置顶,其余按 lastActiveAt 降序
360 Object.keys(groups).forEach(k => { 360 Object.keys(groups).forEach(k => {
361 groups[k].sort((a, b) => { 361 groups[k].sort((a, b) => {
  362 + if (a.type === 'master') return -1
  363 + if (b.type === 'master') return 1
362 const ta = a.lastActiveAt ? new Date(a.lastActiveAt).getTime() : 0 364 const ta = a.lastActiveAt ? new Date(a.lastActiveAt).getTime() : 0
363 const tb = b.lastActiveAt ? new Date(b.lastActiveAt).getTime() : 0 365 const tb = b.lastActiveAt ? new Date(b.lastActiveAt).getTime() : 0
364 return tb - ta 366 return tb - ta
@@ -378,6 +380,25 @@ const groupedAgents = computed(() =&gt; { @@ -378,6 +380,25 @@ const groupedAgents = computed(() =&gt; {
378 }) 380 })
379 const activeTab = ref('chat') // 'chat' | 'detail' 381 const activeTab = ref('chat') // 'chat' | 'detail'
380 382
  383 +// 每个 agent 独立一份聊天态(messages/sending/streaming/思考栏/ACP 面板…),
  384 +// 键是 agentId。切换 agent 只是换了 chatState 指向哪个 slice 来渲染,
  385 +// 后台的 fetch 读流循环(handleSend/handleEvent) 认准发起时捕获的 agentId 写自己那份,
  386 +// 不认"当前选中的 agent 是谁"——这样 A 在流式输出时切去看 B,A 不会被打断,
  387 +// B 也不会被 A 的内容/思考栏/等待态污染,回切 A 能看到它这期间的完整进度。
  388 +const chatStates = reactive(new Map())
  389 +function ensureChatState(id) {
  390 + if (!chatStates.has(id)) {
  391 + chatStates.set(id, {
  392 + messages: [], activeConvId: null,
  393 + sending: false, streaming: false, streamContent: '',
  394 + subAgents: {}, subLogList: [], acpLog: [],
  395 + thinkingText: '🏗️ 启动中...', thinkingContent: '', showThinking: true,
  396 + })
  397 + }
  398 + return chatStates.get(id)
  399 +}
  400 +const chatState = computed(() => ensureChatState(agentId.value))
  401 +
381 const agentInfo = ref({ name: 'Agent Creator', emoji: '🏗️', type: 'meta', status: 'ready', greeting: '', bootstrapFiles: [] }) 402 const agentInfo = ref({ name: 'Agent Creator', emoji: '🏗️', type: 'meta', status: 'ready', greeting: '', bootstrapFiles: [] })
382 403
383 // 状态映射:英文 -> 中文 404 // 状态映射:英文 -> 中文
@@ -429,21 +450,10 @@ const filteredConversations = computed(() =&gt; { @@ -429,21 +450,10 @@ const filteredConversations = computed(() =&gt; {
429 if (convFilter.value === 'all') return conversations.value 450 if (convFilter.value === 'all') return conversations.value
430 return conversations.value.filter(c => getConvType(c.conversationId || c.id) === convFilter.value) 451 return conversations.value.filter(c => getConvType(c.conversationId || c.id) === convFilter.value)
431 }) 452 })
432 -const messages = ref([])  
433 const inputText = ref('') 453 const inputText = ref('')
434 const inputFocused = ref(false) 454 const inputFocused = ref(false)
435 -const sending = ref(false)  
436 -const streaming = ref(false)  
437 -const streamContent = ref('')  
438 -const subAgents = ref({})  
439 -const subLogList = ref([])  
440 -const acpLog = ref([])  
441 -const thinkingText = ref('🏗️ 启动中...')  
442 -const thinkingContent = ref('')  
443 -const showThinking = ref(true)  
444 const messageListRef = ref(null) 455 const messageListRef = ref(null)
445 const inputRef = ref(null) 456 const inputRef = ref(null)
446 -const activeConvId = ref(null)  
447 457
448 // Detail mode state 458 // Detail mode state
449 const fileTree = ref(null) 459 const fileTree = ref(null)
@@ -516,16 +526,16 @@ async function uploadPendingFiles() { @@ -516,16 +526,16 @@ async function uploadPendingFiles() {
516 for (const pf of pendingFiles.value) { 526 for (const pf of pendingFiles.value) {
517 formData.append('files', pf.file) 527 formData.append('files', pf.file)
518 } 528 }
519 - if (activeConvId.value) {  
520 - formData.append('conversationId', activeConvId.value) 529 + if (chatState.value.activeConvId) {
  530 + formData.append('conversationId', chatState.value.activeConvId)
521 } 531 }
522 try { 532 try {
523 const r = await fetch(`/api/agent/${agentId.value}/upload`, { method: 'POST', body: formData }) 533 const r = await fetch(`/api/agent/${agentId.value}/upload`, { method: 'POST', body: formData })
524 if (r.ok) { 534 if (r.ok) {
525 const data = await r.json() 535 const data = await r.json()
526 // Auto-set convId if server created one 536 // Auto-set convId if server created one
527 - if (data.conversationId && !activeConvId.value) {  
528 - activeConvId.value = data.conversationId 537 + if (data.conversationId && !chatState.value.activeConvId) {
  538 + chatState.value.activeConvId = data.conversationId
529 } 539 }
530 uploadedFiles.value.push(...(data.uploaded || [])) 540 uploadedFiles.value.push(...(data.uploaded || []))
531 pendingFiles.value = [] 541 pendingFiles.value = []
@@ -536,9 +546,9 @@ async function uploadPendingFiles() { @@ -536,9 +546,9 @@ async function uploadPendingFiles() {
536 } 546 }
537 547
538 async function loadUploadedFiles() { 548 async function loadUploadedFiles() {
539 - if (!agentId.value || !activeConvId.value) { uploadedFiles.value = []; return } 549 + if (!agentId.value || !chatState.value.activeConvId) { uploadedFiles.value = []; return }
540 try { 550 try {
541 - const r = await fetch(`/api/agent/${agentId.value}/uploads/${activeConvId.value}`) 551 + const r = await fetch(`/api/agent/${agentId.value}/uploads/${chatState.value.activeConvId}`)
542 if (r.ok) uploadedFiles.value = await r.json() 552 if (r.ok) uploadedFiles.value = await r.json()
543 else uploadedFiles.value = [] 553 else uploadedFiles.value = []
544 } catch { uploadedFiles.value = [] } 554 } catch { uploadedFiles.value = [] }
@@ -652,16 +662,17 @@ async function loadConversations() { @@ -652,16 +662,17 @@ async function loadConversations() {
652 662
653 async function loadConversation(convId) { 663 async function loadConversation(convId) {
654 if (!agentId.value) return 664 if (!agentId.value) return
  665 + const st = chatState.value
655 try { 666 try {
656 const r = await fetch(`/api/agent/${agentId.value}/conversations/${convId}`) 667 const r = await fetch(`/api/agent/${agentId.value}/conversations/${convId}`)
657 if (r.ok) { 668 if (r.ok) {
658 const conv = await r.json() 669 const conv = await r.json()
659 - messages.value = conv.messages || [] 670 + st.messages = conv.messages || []
660 if (conv.subLogs && conv.subLogs.length > 0) { 671 if (conv.subLogs && conv.subLogs.length > 0) {
661 - subLogList.value = conv.subLogs.map(l => ({ ...l, collapsed: true }))  
662 - } else { subLogList.value = [] } 672 + st.subLogList = conv.subLogs.map(l => ({ ...l, collapsed: true }))
  673 + } else { st.subLogList = [] }
663 } 674 }
664 - } catch { messages.value = []; subLogList.value = [] } 675 + } catch { st.messages = []; st.subLogList = [] }
665 } 676 }
666 677
667 async function loadFileTree() { 678 async function loadFileTree() {
@@ -724,15 +735,16 @@ async function deleteFile(filePath) { @@ -724,15 +735,16 @@ async function deleteFile(filePath) {
724 } 735 }
725 736
726 function newConversation() { 737 function newConversation() {
727 - activeConvId.value = null  
728 - messages.value = []  
729 - streamContent.value = ''  
730 - subAgents.value = {}  
731 - subLogList.value = []  
732 - acpLog.value = [] 738 + const st = chatState.value
  739 + st.activeConvId = null
  740 + st.messages = []
  741 + st.streamContent = ''
  742 + st.subAgents = {}
  743 + st.subLogList = []
  744 + st.acpLog = []
733 pendingFiles.value = [] 745 pendingFiles.value = []
734 uploadedFiles.value = [] 746 uploadedFiles.value = []
735 - 747 +
736 if (route.params.id) { 748 if (route.params.id) {
737 router.replace({ params: { id: route.params.id, agentId: agentId.value, convId: undefined }}) 749 router.replace({ params: { id: route.params.id, agentId: agentId.value, convId: undefined }})
738 } else { 750 } else {
@@ -740,22 +752,34 @@ function newConversation() { @@ -740,22 +752,34 @@ function newConversation() {
740 } 752 }
741 } 753 }
742 754
  755 +// 切 agent:不清空目标 agent 已经攒下的后台状态(可能正在生成中,或者刚刚生成完毕
  756 +// 还没被看到)——只有它是空闲态时才去定位/刷新它的会话,正在 sending 就原样展示缓存。
743 async function switchAgent() { 757 async function switchAgent() {
744 - newConversation()  
745 selectedFile.value = null 758 selectedFile.value = null
746 previewContent.value = null 759 previewContent.value = null
747 fileTree.value = null 760 fileTree.value = null
  761 + const st = chatState.value
  762 + if (route.params.id) {
  763 + router.replace({ params: { id: route.params.id, agentId: agentId.value, convId: st.activeConvId || undefined }})
  764 + } else {
  765 + router.replace({ params: { agentId: agentId.value, convId: st.activeConvId || undefined }})
  766 + }
748 await loadAgentInfo() 767 await loadAgentInfo()
749 await loadConversations() 768 await loadConversations()
750 - if (filteredConversations.value.length > 0) {  
751 - const firstId = filteredConversations.value[0].conversationId || filteredConversations.value[0].id  
752 - await switchConversation(firstId) 769 + if (!st.sending) {
  770 + if (st.activeConvId) {
  771 + await loadConversation(st.activeConvId)
  772 + } else if (filteredConversations.value.length > 0) {
  773 + const firstId = filteredConversations.value[0].conversationId || filteredConversations.value[0].id
  774 + await switchConversation(firstId)
  775 + }
753 } 776 }
754 await loadFileTree() 777 await loadFileTree()
  778 + scrollToBottom()
755 } 779 }
756 780
757 async function switchConversation(convId) { 781 async function switchConversation(convId) {
758 - activeConvId.value = convId 782 + chatState.value.activeConvId = convId
759 if (route.params.id) { 783 if (route.params.id) {
760 router.replace({ params: { id: route.params.id, agentId: agentId.value, convId } }) 784 router.replace({ params: { id: route.params.id, agentId: agentId.value, convId } })
761 } else { 785 } else {
@@ -774,13 +798,14 @@ async function deleteConversation(convId) { @@ -774,13 +798,14 @@ async function deleteConversation(convId) {
774 const r = await fetch(`/api/agent/${agentId.value}/conversations/${convId}`, { method: 'DELETE' }) 798 const r = await fetch(`/api/agent/${agentId.value}/conversations/${convId}`, { method: 'DELETE' })
775 if (r.ok) { 799 if (r.ok) {
776 conversations.value = conversations.value.filter(c => (c.conversationId || c.id) !== convId) 800 conversations.value = conversations.value.filter(c => (c.conversationId || c.id) !== convId)
777 - if (activeConvId.value === convId) {  
778 - activeConvId.value = null  
779 - messages.value = []  
780 - streamContent.value = ''  
781 - subAgents.value = {}  
782 - subLogList.value = []  
783 - acpLog.value = [] 801 + const st = chatState.value
  802 + if (st.activeConvId === convId) {
  803 + st.activeConvId = null
  804 + st.messages = []
  805 + st.streamContent = ''
  806 + st.subAgents = {}
  807 + st.subLogList = []
  808 + st.acpLog = []
784 } 809 }
785 } 810 }
786 } catch (e) { console.error('Delete conv error:', e) } 811 } catch (e) { console.error('Delete conv error:', e) }
@@ -810,9 +835,14 @@ async function copyText(text) { @@ -810,9 +835,14 @@ async function copyText(text) {
810 835
811 // ─── Chat (SSE) ─── 836 // ─── Chat (SSE) ───
812 837
  838 +// requestAgentId 是发起这次发送时的 agent——从这里往下,全部认这个快照,不认
  839 +// "用户现在选中的是谁"。这样切走了也不会打断/污染,切回来能看到完整的后台进度。
813 async function handleSend() { 840 async function handleSend() {
814 const text = inputText.value.trim() 841 const text = inputText.value.trim()
815 - if ((!text && pendingFiles.value.length === 0) || sending.value || !agentId.value) return 842 + const requestAgentId = agentId.value
  843 + const st = ensureChatState(requestAgentId)
  844 + if ((!text && pendingFiles.value.length === 0) || st.sending || !requestAgentId) return
  845 + const isCurrent = () => agentId.value === requestAgentId
816 846
817 // Upload pending files first 847 // Upload pending files first
818 if (pendingFiles.value.length > 0) { 848 if (pendingFiles.value.length > 0) {
@@ -821,27 +851,26 @@ async function handleSend() { @@ -821,27 +851,26 @@ async function handleSend() {
821 851
822 const finalText = text || '请处理我上传的文件。' 852 const finalText = text || '请处理我上传的文件。'
823 const attachments = uploadedFiles.value.length > 0 ? [...uploadedFiles.value] : [] 853 const attachments = uploadedFiles.value.length > 0 ? [...uploadedFiles.value] : []
824 - messages.value.push({ role: 'user', content: finalText, timestamp: new Date().toISOString(), attachments }) 854 + st.messages.push({ role: 'user', content: finalText, timestamp: new Date().toISOString(), attachments })
825 inputText.value = '' 855 inputText.value = ''
826 if (inputRef.value) inputRef.value.style.height = 'auto' 856 if (inputRef.value) inputRef.value.style.height = 'auto'
827 - sending.value = true; streaming.value = false; streamContent.value = ''  
828 - subAgents.value = {}; acpLog.value = []; thinkingContent.value = ''  
829 - showThinking.value = true  
830 - thinkingText.value = `${agentInfo.value.emoji} ${agentInfo.value.name} 思考中...`  
831 - activeTab.value = 'chat' // ensure we're in chat mode  
832 - scrollToBottom()  
833 -  
834 - if (activeConvId.value) {  
835 - const existsInList = conversations.value.some(c => (c.conversationId || c.id) === activeConvId.value) 857 + st.sending = true; st.streaming = false; st.streamContent = ''
  858 + st.subAgents = {}; st.acpLog = []; st.thinkingContent = ''
  859 + st.showThinking = true
  860 + st.thinkingText = `${agentInfo.value.emoji} ${agentInfo.value.name} 思考中...`
  861 + if (isCurrent()) { activeTab.value = 'chat'; scrollToBottom() }
  862 +
  863 + if (st.activeConvId && isCurrent()) {
  864 + const existsInList = conversations.value.some(c => (c.conversationId || c.id) === st.activeConvId)
836 if (!existsInList) { 865 if (!existsInList) {
837 - conversations.value.unshift({ conversationId: activeConvId.value, title: finalText.slice(0, 50), updatedAt: new Date().toISOString(), messageCount: messages.value.length, status: 'running' }) 866 + conversations.value.unshift({ conversationId: st.activeConvId, title: finalText.slice(0, 50), updatedAt: new Date().toISOString(), messageCount: st.messages.length, status: 'running' })
838 } 867 }
839 } 868 }
840 869
841 try { 870 try {
842 - const r = await fetch(`/api/agent/${agentId.value}/chat`, { 871 + const r = await fetch(`/api/agent/${requestAgentId}/chat`, {
843 method: 'POST', headers: { 'Content-Type': 'application/json' }, 872 method: 'POST', headers: { 'Content-Type': 'application/json' },
844 - body: JSON.stringify({ message: finalText, conversationId: activeConvId.value }), 873 + body: JSON.stringify({ message: finalText, conversationId: st.activeConvId }),
845 }) 874 })
846 const reader = r.body.getReader() 875 const reader = r.body.getReader()
847 const decoder = new TextDecoder() 876 const decoder = new TextDecoder()
@@ -855,86 +884,93 @@ async function handleSend() { @@ -855,86 +884,93 @@ async function handleSend() {
855 for (const line of lines) { 884 for (const line of lines) {
856 if (line.startsWith('event: ')) lastEventType = line.slice(7) 885 if (line.startsWith('event: ')) lastEventType = line.slice(7)
857 else if (line.startsWith('data: ')) { 886 else if (line.startsWith('data: ')) {
858 - try { handleEvent(lastEventType, JSON.parse(line.slice(6))) } catch {} 887 + try { handleEvent(st, requestAgentId, lastEventType, JSON.parse(line.slice(6))) } catch {}
859 } 888 }
860 } 889 }
861 } 890 }
862 - if (streamContent.value) {  
863 - messages.value.push({ role: 'assistant', content: streamContent.value, timestamp: new Date().toISOString() }) 891 + if (st.streamContent) {
  892 + st.messages.push({ role: 'assistant', content: st.streamContent, timestamp: new Date().toISOString() })
864 } 893 }
865 - await loadConversations()  
866 - if (activeConvId.value) await loadSubLogs() 894 + if (isCurrent()) await loadConversations()
  895 + if (st.activeConvId) await loadSubLogsFor(requestAgentId, st)
867 } catch (err) { 896 } catch (err) {
868 - messages.value.push({ role: 'assistant', content: `❌ 错误: ${err.message}`, timestamp: new Date().toISOString() }) 897 + st.messages.push({ role: 'assistant', content: `❌ 错误: ${err.message}`, timestamp: new Date().toISOString() })
869 } finally { 898 } finally {
870 - sending.value = false; streaming.value = false; streamContent.value = ''; thinkingContent.value = '' 899 + st.sending = false; st.streaming = false; st.streamContent = ''; st.thinkingContent = ''
871 } 900 }
872 } 901 }
873 902
874 -async function loadSubLogs() { 903 +async function loadSubLogsFor(id, st) {
875 try { 904 try {
876 - const r = await fetch(`/api/agent/${agentId.value}/conversations/${activeConvId.value}/sub-logs`)  
877 - if (r.ok) { const logs = await r.json(); subLogList.value = logs.map(l => ({ ...l, collapsed: true })) } 905 + const r = await fetch(`/api/agent/${id}/conversations/${st.activeConvId}/sub-logs`)
  906 + if (r.ok) { const logs = await r.json(); st.subLogList = logs.map(l => ({ ...l, collapsed: true })) }
878 } catch {} 907 } catch {}
879 } 908 }
880 909
881 async function handleStop() { 910 async function handleStop() {
882 - if (!activeConvId.value) return  
883 - try { await fetch(`/api/agent/${agentId.value}/chat/${activeConvId.value}/stop`, { method: 'POST' }) } catch(e) { console.error('Stop error:', e) }  
884 - sending.value = false; streaming.value = false  
885 - messages.value.push({ role: 'assistant', content: `*[已终止]*`, timestamp: new Date().toISOString() }) 911 + const st = chatState.value
  912 + if (!st.activeConvId) return
  913 + try { await fetch(`/api/agent/${agentId.value}/chat/${st.activeConvId}/stop`, { method: 'POST' }) } catch(e) { console.error('Stop error:', e) }
  914 + st.sending = false; st.streaming = false
  915 + st.messages.push({ role: 'assistant', content: `*[已终止]*`, timestamp: new Date().toISOString() })
886 scrollToBottom() 916 scrollToBottom()
887 } 917 }
888 918
889 -function handleEvent(type, data) { 919 +// st/requestAgentId 是 handleSend 传下来的快照;只有 requestAgentId 仍是当前选中 agent 时
  920 +// 才动 DOM(滚动)/URL/全局对话列表——避免把某个后台 agent 的进度"带"到当前正看的页面上。
  921 +function handleEvent(st, requestAgentId, type, data) {
  922 + const isCurrent = agentId.value === requestAgentId
890 switch (type) { 923 switch (type) {
891 case 'session': 924 case 'session':
892 - if (data.conversationId && !activeConvId.value) {  
893 - activeConvId.value = data.conversationId  
894 - if (route.params.id) {  
895 - router.replace({ params: { id: route.params.id, agentId: agentId.value, convId: data.conversationId } })  
896 - } else {  
897 - router.replace({ params: { agentId: agentId.value, convId: data.conversationId } })  
898 - }  
899 - const existsInList = conversations.value.some(c => (c.conversationId || c.id) === data.conversationId)  
900 - if (!existsInList) {  
901 - conversations.value.unshift({ conversationId: data.conversationId, title: messages.value.find(m => m.role === 'user')?.content?.slice(0, 50) || '新对话', updatedAt: new Date().toISOString(), messageCount: 1, status: 'running' }) 925 + if (data.conversationId && !st.activeConvId) {
  926 + st.activeConvId = data.conversationId
  927 + if (isCurrent) {
  928 + if (route.params.id) {
  929 + router.replace({ params: { id: route.params.id, agentId: requestAgentId, convId: data.conversationId } })
  930 + } else {
  931 + router.replace({ params: { agentId: requestAgentId, convId: data.conversationId } })
  932 + }
  933 + const existsInList = conversations.value.some(c => (c.conversationId || c.id) === data.conversationId)
  934 + if (!existsInList) {
  935 + conversations.value.unshift({ conversationId: data.conversationId, title: st.messages.find(m => m.role === 'user')?.content?.slice(0, 50) || '新对话', updatedAt: new Date().toISOString(), messageCount: 1, status: 'running' })
  936 + }
902 } 937 }
903 } 938 }
904 break 939 break
905 case 'thinking': 940 case 'thinking':
906 - if (data.message && !data.message.startsWith('🏗️')) { thinkingContent.value += data.message; scrollToBottom() }  
907 - else { thinkingText.value = data.message || '思考中...' } 941 + if (data.message && !data.message.startsWith('🏗️')) { st.thinkingContent += data.message; if (isCurrent) scrollToBottom() }
  942 + else { st.thinkingText = data.message || '思考中...' }
908 break 943 break
909 case 'agent_output': 944 case 'agent_output':
910 - streaming.value = true; streamContent.value += (data.message || ''); scrollToBottom(); break 945 + st.streaming = true; st.streamContent += (data.message || ''); if (isCurrent) scrollToBottom(); break
911 case 'tool_call': 946 case 'tool_call':
912 - thinkingText.value = data.message || `🔧 ${data.tool}`  
913 - acpLog.value.push({ type: 'tool', icon: '🔧', text: `${data.tool}`, ts: Date.now() }); scrollAcpLog(); break 947 + st.thinkingText = data.message || `🔧 ${data.tool}`
  948 + st.acpLog.push({ type: 'tool', icon: '🔧', text: `${data.tool}`, ts: Date.now() }); if (isCurrent) scrollAcpLog(); break
914 case 'tool_input': 949 case 'tool_input':
915 - acpLog.value.push({ type: 'input', icon: '📥', text: data.input?.slice(0, 300) || '', ts: Date.now() }); scrollAcpLog(); break 950 + st.acpLog.push({ type: 'input', icon: '📥', text: data.input?.slice(0, 300) || '', ts: Date.now() }); if (isCurrent) scrollAcpLog(); break
916 case 'tool_result': 951 case 'tool_result':
917 - acpLog.value.push({ type: 'result', icon: '📤', text: data.result?.slice(0, 300) || '', ts: Date.now() }); scrollAcpLog(); break 952 + st.acpLog.push({ type: 'result', icon: '📤', text: data.result?.slice(0, 300) || '', ts: Date.now() }); if (isCurrent) scrollAcpLog(); break
918 case 'session_cost': 953 case 'session_cost':
919 - acpLog.value.push({ type: 'cost', icon: '💰', text: `$${data.cost?.toFixed(4)} (${((data.duration||0)/1000).toFixed(1)}s)`, ts: Date.now() }); break 954 + st.acpLog.push({ type: 'cost', icon: '💰', text: `$${data.cost?.toFixed(4)} (${((data.duration||0)/1000).toFixed(1)}s)`, ts: Date.now() }); break
920 case 'acp_raw': 955 case 'acp_raw':
921 if (data.method && !data.method.includes('content_block_delta') && !data.method.includes('message_start')) { 956 if (data.method && !data.method.includes('content_block_delta') && !data.method.includes('message_start')) {
922 - acpLog.value.push({ type: 'raw', icon: '⚡', text: data.method, ts: Date.now() }); scrollAcpLog() 957 + st.acpLog.push({ type: 'raw', icon: '⚡', text: data.method, ts: Date.now() }); if (isCurrent) scrollAcpLog()
923 } 958 }
924 break 959 break
925 case 'sub_agent': 960 case 'sub_agent':
926 - if (!subAgents.value[data.callId]) subAgents.value[data.callId] = { agent: data.agent, status: 'running', content: '', collapsed: false } 961 + if (!st.subAgents[data.callId]) st.subAgents[data.callId] = { agent: data.agent, status: 'running', content: '', collapsed: false }
927 if (data.event === 'chunk' && data.chunk) { 962 if (data.event === 'chunk' && data.chunk) {
928 - subAgents.value[data.callId].content += data.chunk  
929 - setTimeout(() => { document.querySelectorAll('.sub-log-content').forEach(el => el.scrollTop = el.scrollHeight) }, 50)  
930 - } else if (data.event === 'done') subAgents.value[data.callId].status = data.status  
931 - else if (data.event === 'start') subAgents.value[data.callId].status = 'running'  
932 - scrollToBottom(); break 963 + st.subAgents[data.callId].content += data.chunk
  964 + if (isCurrent) setTimeout(() => { document.querySelectorAll('.sub-log-content').forEach(el => el.scrollTop = el.scrollHeight) }, 50)
  965 + } else if (data.event === 'done') st.subAgents[data.callId].status = data.status
  966 + else if (data.event === 'start') st.subAgents[data.callId].status = 'running'
  967 + if (isCurrent) scrollToBottom(); break
933 case 'error': 968 case 'error':
934 - messages.value.push({ role: 'assistant', content: `❌ ${data.message}`, timestamp: new Date().toISOString() })  
935 - acpLog.value.push({ type: 'error', icon: '🚨', text: data.message, ts: Date.now() })  
936 - thinkingText.value = ''; thinkingContent.value = ''  
937 - scrollToBottom(); scrollAcpLog(); break 969 + st.messages.push({ role: 'assistant', content: `❌ ${data.message}`, timestamp: new Date().toISOString() })
  970 + st.acpLog.push({ type: 'error', icon: '🚨', text: data.message, ts: Date.now() })
  971 + st.thinkingText = ''; st.thinkingContent = ''
  972 + if (isCurrent) { scrollToBottom(); scrollAcpLog() }
  973 + break
938 } 974 }
939 } 975 }
940 976
@@ -957,7 +993,7 @@ function renderMarkdown(text) { @@ -957,7 +993,7 @@ function renderMarkdown(text) {
957 catch { return (text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>') } 993 catch { return (text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>') }
958 } 994 }
959 995
960 -watch([messages, streamContent], () => scrollToBottom(), { deep: true }) 996 +watch(chatState, () => scrollToBottom(), { deep: true })
961 // Load file tree when switching to detail tab 997 // Load file tree when switching to detail tab
962 watch(activeTab, async (tab) => { if (tab === 'detail' && !fileTree.value) await loadFileTree() }) 998 watch(activeTab, async (tab) => { if (tab === 'detail' && !fileTree.value) await loadFileTree() })
963 999
@@ -975,7 +1011,7 @@ onMounted(async () =&gt; { @@ -975,7 +1011,7 @@ onMounted(async () =&gt; {
975 await loadAgentInfo() 1011 await loadAgentInfo()
976 await loadConversations() 1012 await loadConversations()
977 if (route.params.convId) { 1013 if (route.params.convId) {
978 - activeConvId.value = route.params.convId 1014 + chatState.value.activeConvId = route.params.convId
979 await loadConversation(route.params.convId) 1015 await loadConversation(route.params.convId)
980 scrollToBottom() 1016 scrollToBottom()
981 } else if (filteredConversations.value.length > 0) { 1017 } else if (filteredConversations.value.length > 0) {