fix(web): 子代理状态显示与渲染崩溃修复

MessageBubble:
- taskStatusConfig 补全 running/completed/cancelled/interrupted 状态配色,
  修复未知状态访问 undefined.borderColor 导致的白屏崩溃
- 添加安全回退到 failed 状态

main.tsx:
- 添加 ErrorBoundary 捕获 React 渲染错误,防止白屏并提供错误详情与恢复入口

useMessages:
- 收到子代理 execution_completed 事件后,更新主视图中 task tool result 占位消息的
  status 字段(running->completed/failed/cancelled),消除永久黄色转圈

protocol.ts:
- ExecutionCompleted 接口添加 subagent_status 和 subagent_summary 字段
This commit is contained in:
oudecheng 2026-08-13 22:12:43 +08:00
parent fc3a95b152
commit daf973a7dc
4 changed files with 127 additions and 10 deletions

View File

@ -34,13 +34,14 @@ function StatusIcon({
status, status,
size = 14, size = 14,
}: { }: {
status: 'calling' | 'result' | 'pending' | 'success' | 'failed' | 'timeout'; status: string;
size?: number; size?: number;
}) { }) {
const iconClass = `transition-all duration-300`; const iconClass = `transition-all duration-300`;
switch (status) { switch (status) {
case 'calling': case 'calling':
case 'running':
return ( return (
<Loader2 <Loader2
className={`${iconClass} animate-spin`} className={`${iconClass} animate-spin`}
@ -50,6 +51,7 @@ function StatusIcon({
); );
case 'result': case 'result':
case 'success': case 'success':
case 'completed':
return ( return (
<CheckCircle <CheckCircle
className={`${iconClass} animate-scale-in`} className={`${iconClass} animate-scale-in`}
@ -74,6 +76,8 @@ function StatusIcon({
/> />
); );
case 'pending': case 'pending':
case 'interrupted':
case 'cancelled':
return ( return (
<Loader <Loader
className={`${iconClass} animate-spin`} className={`${iconClass} animate-spin`}
@ -437,19 +441,49 @@ export const MessageBubble = memo(function MessageBubble({
((message.arguments as Record<string, unknown> | null)?.prompt as string) || ''; ((message.arguments as Record<string, unknown> | null)?.prompt as string) || '';
// task tool 专用的状态配色 // task tool 专用的状态配色
const taskStatusConfig = { // 支持的状态:
// - running: 异步子代理刚 spawn占位结果
// - success/completed: 子代理执行成功
// - failed: 子代理执行失败
// - timeout: 子代理执行超时
// - cancelled: 子代理被用户取消
// - interrupted: 子代理因服务器重启被中断
const taskStatusConfig: Record<string, { dot: string; borderColor: string; iconColor: string }> = {
running: {
dot: 'bg-amber-400 animate-pulse',
borderColor: 'border-amber-500/40',
iconColor: 'text-amber-400',
},
success: { success: {
dot: 'bg-emerald-400', dot: 'bg-emerald-400',
borderColor: 'border-emerald-500/40', borderColor: 'border-emerald-500/40',
iconColor: 'text-emerald-400', iconColor: 'text-emerald-400',
}, },
completed: {
dot: 'bg-emerald-400',
borderColor: 'border-emerald-500/40',
iconColor: 'text-emerald-400',
},
failed: { dot: 'bg-red-400', borderColor: 'border-red-500/40', iconColor: 'text-red-400' }, failed: { dot: 'bg-red-400', borderColor: 'border-red-500/40', iconColor: 'text-red-400' },
timeout: { timeout: {
dot: 'bg-amber-400', dot: 'bg-amber-400',
borderColor: 'border-amber-500/40', borderColor: 'border-amber-500/40',
iconColor: 'text-amber-400', iconColor: 'text-amber-400',
}, },
} as const; cancelled: {
dot: 'bg-zinc-400',
borderColor: 'border-zinc-500/40',
iconColor: 'text-zinc-400',
},
interrupted: {
dot: 'bg-orange-400',
borderColor: 'border-orange-500/40',
iconColor: 'text-orange-400',
},
};
// 安全获取 task 状态配色,未知状态回退到默认(避免 undefined.borderColor 崩溃)
const taskStyle = taskResult ? (taskStatusConfig[taskResult.status] ?? taskStatusConfig.failed) : null;
return ( return (
<div data-message-id={message.id} className="flex gap-3 animate-slide-in"> <div data-message-id={message.id} className="flex gap-3 animate-slide-in">
@ -481,14 +515,14 @@ export const MessageBubble = memo(function MessageBubble({
<div <div
onClick={() => setToolExpanded(!toolExpanded)} onClick={() => setToolExpanded(!toolExpanded)}
className={`cursor-pointer rounded-xl border bg-[var(--bg-tertiary)]/60 w-full transition-all duration-500 hover:bg-[var(--bg-tertiary)]/80 group ${ className={`cursor-pointer rounded-xl border bg-[var(--bg-tertiary)]/60 w-full transition-all duration-500 hover:bg-[var(--bg-tertiary)]/80 group ${
taskResult ? taskStatusConfig[taskResult.status].borderColor : statusConfig.fullBorder taskStyle ? taskStyle.borderColor : statusConfig.fullBorder
}`} }`}
> >
{/* Header row */} {/* Header row */}
<div className="flex items-center gap-2 px-3 py-2"> <div className="flex items-center gap-2 px-3 py-2">
<span <span
className={`inline-block h-2 w-2 rounded-full flex-shrink-0 transition-colors duration-500 ${ className={`inline-block h-2 w-2 rounded-full flex-shrink-0 transition-colors duration-500 ${
taskResult ? taskStatusConfig[taskResult.status].dot : statusConfig.dot taskStyle ? taskStyle.dot : statusConfig.dot
}`} }`}
/> />
<span className="text-sm font-medium text-[var(--text-secondary)] truncate"> <span className="text-sm font-medium text-[var(--text-secondary)] truncate">
@ -496,9 +530,7 @@ export const MessageBubble = memo(function MessageBubble({
</span> </span>
<span <span
className={`flex-shrink-0 transition-all duration-300 ${ className={`flex-shrink-0 transition-all duration-300 ${
taskResult taskStyle ? taskStyle.iconColor : statusConfig.iconColor
? taskStatusConfig[taskResult.status].iconColor
: statusConfig.iconColor
}`} }`}
> >
{taskResult ? ( {taskResult ? (

View File

@ -314,6 +314,39 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
if (getSubagentTaskId(message)) { if (getSubagentTaskId(message)) {
// 子代理执行完成bump 统一 triggerApp.tsx 根据 subAgentView 分派 load_task_messages // 子代理执行完成bump 统一 triggerApp.tsx 根据 subAgentView 分派 load_task_messages
bumpTopicRefreshTrigger(); bumpTopicRefreshTrigger();
// 更新主视图中 task tool result 占位消息的状态
// task 工具返回时 status='running'(黄色转圈),子代理完成后需更新为最终状态
if (msg.subagent_task_id && msg.subagent_status) {
const taskId = msg.subagent_task_id;
const newStatus = msg.subagent_status;
const newSummary = msg.subagent_summary;
setMessages((prev) => {
let changed = false;
const updated = prev.map((m) => {
if (m.type !== 'tool_result' || m.toolName !== 'task') return m;
if (!m.content) return m;
// content 是 TaskToolResult JSON可能带 loop_detector 前缀)
const jsonStart = m.content.indexOf('{');
if (jsonStart < 0) return m;
try {
const parsed = JSON.parse(m.content.slice(jsonStart));
if (parsed.task_id !== taskId) return m;
if (parsed.status === newStatus) return m;
parsed.status = newStatus;
if (newSummary !== undefined) parsed.summary = newSummary;
const newJson = JSON.stringify(parsed);
changed = true;
const prefix = jsonStart > 0 ? m.content.slice(0, jsonStart) : '';
return { ...m, content: prefix + newJson };
} catch {
return m;
}
});
return changed ? updated : prev;
});
}
return true; return true;
} }
// 按 topic_id 移除处理状态,不论当前选中哪个话题。 // 按 topic_id 移除处理状态,不论当前选中哪个话题。

View File

@ -3,8 +3,49 @@ import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import './index.css'; import './index.css';
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean; error: Error | null }
> {
constructor(props: { children: React.ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, info.componentStack);
}
render() {
if (this.state.hasError) {
return (
<div style={{ padding: '20px', color: '#ff6b6b', background: '#1a1a1a', minHeight: '100vh', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
<h2>React Render Error</h2>
<p><strong>{this.state.error?.name}:</strong> {this.state.error?.message}</p>
<pre>{this.state.error?.stack}</pre>
<hr />
<p>Try clearing browser cache and localStorage, then refresh.</p>
<button
onClick={() => { localStorage.clear(); location.reload(); }}
style={{ marginTop: '10px', padding: '8px 16px', cursor: 'pointer' }}
>
Clear localStorage & Refresh
</button>
</div>
);
}
return this.props.children;
}
}
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@ -303,6 +303,9 @@ export interface ExecutionCompleted {
topic_id?: string; topic_id?: string;
timestamp?: number; timestamp?: number;
subagent_task_id?: string; subagent_task_id?: string;
/** 子代理最终状态completed/failed/timeout/cancelled/interrupted */
subagent_status?: string;
subagent_summary?: string;
} }
export type WsOutbound = export type WsOutbound =
@ -504,7 +507,15 @@ export interface ChatMessage {
/** task 工具返回的 JSON 结构 */ /** task 工具返回的 JSON 结构 */
export interface TaskToolResult { export interface TaskToolResult {
status: 'success' | 'failed' | 'timeout'; // status 值由后端 TaskToolResult.status 决定,包括:
// - running: 异步子代理刚 spawn 的占位结果
// - success: 子代理执行成功(旧)
// - completed: 子代理执行成功(新,与 SubagentStatus 对齐)
// - failed: 子代理执行失败
// - timeout: 子代理执行超时
// - cancelled: 子代理被用户取消
// - interrupted: 子代理因服务器重启被中断
status: string;
summary: string; summary: string;
output: string; output: string;
task_id: string; task_id: string;