From 8cf5cb9d07d33177047bceb03c76c0de65fada9b Mon Sep 17 00:00:00 2001 From: Andreaseszhang Date: Fri, 3 Apr 2026 17:32:32 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20subagent=20spinner=20=E5=9C=A8=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=BB=88=E6=AD=A2=E4=BB=BB=E5=8A=A1=E5=90=8E=E4=BB=8D?= =?UTF-8?q?=E6=8C=81=E7=BB=AD=E8=BD=AC=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:用户终止 agent 任务后,子代理(Agent/Task 工具调用)的 spinner 持续转动。 根因:SDK 进程被 abort 后不再发 tool_result 事件,UI 通过检查 JSONL 中是否存在 tool_result 来决定 spinner 状态——缺失 result 导致 spinner 永不停止。 修复方案: - ContentBlock 新增 isStreaming prop,仅流式中的未完成工具调用才显示 spinner - 持久化消息(已结束的 turn)中缺失 result 的工具调用视为已中断,不显示 spinner - handleStop/STREAM_COMPLETE/complete 事件中兜底标记 toolActivities 为 done Co-Authored-By: Claude Opus 4.6 --- .../src/renderer/atoms/agent-atoms.ts | 5 ++++- .../renderer/components/agent/AgentView.tsx | 15 +++++++++++++- .../components/agent/ContentBlock.tsx | 20 ++++++++++++------- .../components/agent/SDKMessageRenderer.tsx | 1 + .../renderer/hooks/useGlobalAgentListeners.ts | 14 ++++++++++++- 5 files changed, 45 insertions(+), 10 deletions(-) diff --git a/apps/electron/src/renderer/atoms/agent-atoms.ts b/apps/electron/src/renderer/atoms/agent-atoms.ts index f7cec51..eadfc4a 100644 --- a/apps/electron/src/renderer/atoms/agent-atoms.ts +++ b/apps/electron/src/renderer/atoms/agent-atoms.ts @@ -603,10 +603,13 @@ export function applyAgentEvent( // 成功完成 — 清除 retrying,但保持 running: true // 等待 STREAM_COMPLETE IPC 回调通过删除流式状态来控制 UI 就绪状态 // 这避免了用户在后端尚未完成清理时就能发送新消息的竞态条件 - // 同时将仍 running 的 teammates 标记为 stopped(兜底) + // 同时将仍 running 的 teammates 标记为 stopped、未完成的工具活动标记为 done(兜底) return { ...prev, retrying: undefined, + toolActivities: prev.toolActivities.map((ta) => + ta.done ? ta : { ...ta, done: true } + ), teammates: prev.teammates.map((tm) => tm.status === 'running' ? { ...tm, status: 'stopped' as const, endedAt: Date.now(), currentToolName: undefined, currentToolElapsedSeconds: undefined, currentToolUseId: undefined } diff --git a/apps/electron/src/renderer/components/agent/AgentView.tsx b/apps/electron/src/renderer/components/agent/AgentView.tsx index d205ca9..8ea2efb 100644 --- a/apps/electron/src/renderer/components/agent/AgentView.tsx +++ b/apps/electron/src/renderer/components/agent/AgentView.tsx @@ -906,7 +906,20 @@ export function AgentView({ sessionId }: { sessionId: string }): React.ReactElem const current = prev.get(sessionId) if (!current) return prev const map = new Map(prev) - map.set(sessionId, { ...current, running: false }) + map.set(sessionId, { + ...current, + running: false, + // 将所有未完成的工具活动标记为已完成,防止 spinner 继续转动 + toolActivities: current.toolActivities.map((ta) => + ta.done ? ta : { ...ta, done: true } + ), + // 将所有 running 的 teammates 标记为 stopped + teammates: current.teammates.map((tm) => + tm.status === 'running' + ? { ...tm, status: 'stopped' as const, endedAt: Date.now(), currentToolName: undefined, currentToolElapsedSeconds: undefined, currentToolUseId: undefined } + : tm + ), + }) return map }) diff --git a/apps/electron/src/renderer/components/agent/ContentBlock.tsx b/apps/electron/src/renderer/components/agent/ContentBlock.tsx index 4c5b818..12ea5e5 100644 --- a/apps/electron/src/renderer/components/agent/ContentBlock.tsx +++ b/apps/electron/src/renderer/components/agent/ContentBlock.tsx @@ -204,6 +204,8 @@ export interface ContentBlockProps { dimmed?: boolean /** 子代理的内容块(Agent/Task 工具调用的嵌套子块) */ childBlocks?: SDKContentBlock[] + /** 是否正在流式输出中(仅流式中的未完成工具调用才显示 spinner) */ + isStreaming?: boolean } // ===== 提示词折叠行 ===== @@ -262,9 +264,11 @@ interface ToolUseBlockProps { dimmed?: boolean childBlocks?: SDKContentBlock[] basePath?: string + /** 是否正在流式输出中 */ + isStreaming?: boolean } -function ToolUseBlock({ block, allMessages, animate = false, index = 0, dimmed = false, childBlocks, basePath }: ToolUseBlockProps): React.ReactElement { +function ToolUseBlock({ block, allMessages, animate = false, index = 0, dimmed = false, childBlocks, basePath, isStreaming }: ToolUseBlockProps): React.ReactElement { const [expanded, setExpanded] = React.useState(false) const toolResult = useToolResult(block.id, allMessages) const isAgentTool = block.name === 'Agent' || block.name === 'Task' @@ -280,8 +284,8 @@ function ToolUseBlock({ block, allMessages, animate = false, index = 0, dimmed = const isCompleted = toolResult !== null const isError = toolResult?.isError === true - // 运行中显示进行时短语,完成后显示完成态短语 - const displayLabel = isCompleted ? phrase.label : phrase.loadingLabel + // 运行中显示进行时短语,完成或非流式(已终止)显示完成态短语 + const displayLabel = (isCompleted || !isStreaming) ? phrase.label : phrase.loadingLabel const delay = animate && index < 10 ? `${index * 30}ms` : '0ms' @@ -315,8 +319,8 @@ function ToolUseBlock({ block, allMessages, animate = false, index = 0, dimmed = )} /> - {/* 状态指示 */} - {!isCompleted ? ( + {/* 状态指示:仅流式中的未完成工具才显示 spinner */} + {!isCompleted && isStreaming ? ( ) : isError ? ( @@ -353,6 +357,7 @@ function ToolUseBlock({ block, allMessages, animate = false, index = 0, dimmed = animate index={ci} dimmed + isStreaming={isStreaming} /> ))} @@ -382,7 +387,7 @@ function ToolUseBlock({ block, allMessages, animate = false, index = 0, dimmed = className="flex items-center gap-2 py-0.5 text-left hover:opacity-70 transition-opacity group" onClick={() => setExpanded(!expanded)} > - {!isCompleted ? ( + {!isCompleted && isStreaming ? ( ) : isError ? ( @@ -510,7 +515,7 @@ function ThinkingBlock({ block, dimmed = false }: ThinkingBlockProps): React.Rea // ===== ContentBlock 主组件 ===== -export function ContentBlock({ block, allMessages, basePath, animate = false, index = 0, dimmed = false, childBlocks }: ContentBlockProps): React.ReactElement | null { +export function ContentBlock({ block, allMessages, basePath, animate = false, index = 0, dimmed = false, childBlocks, isStreaming }: ContentBlockProps): React.ReactElement | null { // text 块 — 主要内容,不受 dimmed 影响 if (block.type === 'text') { const textBlock = block as SDKTextBlock @@ -532,6 +537,7 @@ export function ContentBlock({ block, allMessages, basePath, animate = false, in dimmed={dimmed} childBlocks={childBlocks} basePath={basePath} + isStreaming={isStreaming} /> ) } diff --git a/apps/electron/src/renderer/components/agent/SDKMessageRenderer.tsx b/apps/electron/src/renderer/components/agent/SDKMessageRenderer.tsx index 57632e9..66b5337 100644 --- a/apps/electron/src/renderer/components/agent/SDKMessageRenderer.tsx +++ b/apps/electron/src/renderer/components/agent/SDKMessageRenderer.tsx @@ -412,6 +412,7 @@ export function AssistantTurnRenderer({ turn, allMessages, basePath, onFork, isS index={i} dimmed={hasTextContent && block.type !== 'text'} childBlocks={childBlocks} + isStreaming={isStreaming} /> ) })} diff --git a/apps/electron/src/renderer/hooks/useGlobalAgentListeners.ts b/apps/electron/src/renderer/hooks/useGlobalAgentListeners.ts index fe9bd61..f71c214 100644 --- a/apps/electron/src/renderer/hooks/useGlobalAgentListeners.ts +++ b/apps/electron/src/renderer/hooks/useGlobalAgentListeners.ts @@ -462,12 +462,24 @@ export function useGlobalAgentListeners(): void { ) // STREAM_COMPLETE 表示后端已完全结束 — 立即标记 running: false + // 同时将所有未完成的工具活动标记为已完成,防止 subagent spinner 继续转动 // (complete 事件只清除 retrying,保持 running: true 以防竞态) store.set(agentStreamingStatesAtom, (prev) => { const current = prev.get(data.sessionId) if (!current || !current.running) return prev const map = new Map(prev) - map.set(data.sessionId, { ...current, running: false }) + map.set(data.sessionId, { + ...current, + running: false, + toolActivities: current.toolActivities.map((ta) => + ta.done ? ta : { ...ta, done: true } + ), + teammates: current.teammates.map((tm) => + tm.status === 'running' + ? { ...tm, status: 'stopped' as const, endedAt: Date.now(), currentToolName: undefined, currentToolElapsedSeconds: undefined, currentToolUseId: undefined } + : tm + ), + }) return map })