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:
parent
fc3a95b152
commit
daf973a7dc
@ -34,13 +34,14 @@ function StatusIcon({
|
||||
status,
|
||||
size = 14,
|
||||
}: {
|
||||
status: 'calling' | 'result' | 'pending' | 'success' | 'failed' | 'timeout';
|
||||
status: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const iconClass = `transition-all duration-300`;
|
||||
|
||||
switch (status) {
|
||||
case 'calling':
|
||||
case 'running':
|
||||
return (
|
||||
<Loader2
|
||||
className={`${iconClass} animate-spin`}
|
||||
@ -50,6 +51,7 @@ function StatusIcon({
|
||||
);
|
||||
case 'result':
|
||||
case 'success':
|
||||
case 'completed':
|
||||
return (
|
||||
<CheckCircle
|
||||
className={`${iconClass} animate-scale-in`}
|
||||
@ -74,6 +76,8 @@ function StatusIcon({
|
||||
/>
|
||||
);
|
||||
case 'pending':
|
||||
case 'interrupted':
|
||||
case 'cancelled':
|
||||
return (
|
||||
<Loader
|
||||
className={`${iconClass} animate-spin`}
|
||||
@ -437,19 +441,49 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
((message.arguments as Record<string, unknown> | null)?.prompt as string) || '';
|
||||
|
||||
// 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: {
|
||||
dot: 'bg-emerald-400',
|
||||
borderColor: 'border-emerald-500/40',
|
||||
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' },
|
||||
timeout: {
|
||||
dot: 'bg-amber-400',
|
||||
borderColor: 'border-amber-500/40',
|
||||
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 (
|
||||
<div data-message-id={message.id} className="flex gap-3 animate-slide-in">
|
||||
@ -481,14 +515,14 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
<div
|
||||
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 ${
|
||||
taskResult ? taskStatusConfig[taskResult.status].borderColor : statusConfig.fullBorder
|
||||
taskStyle ? taskStyle.borderColor : statusConfig.fullBorder
|
||||
}`}
|
||||
>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<span
|
||||
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">
|
||||
@ -496,9 +530,7 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
</span>
|
||||
<span
|
||||
className={`flex-shrink-0 transition-all duration-300 ${
|
||||
taskResult
|
||||
? taskStatusConfig[taskResult.status].iconColor
|
||||
: statusConfig.iconColor
|
||||
taskStyle ? taskStyle.iconColor : statusConfig.iconColor
|
||||
}`}
|
||||
>
|
||||
{taskResult ? (
|
||||
|
||||
@ -314,6 +314,39 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
||||
if (getSubagentTaskId(message)) {
|
||||
// 子代理执行完成:bump 统一 trigger,App.tsx 根据 subAgentView 分派 load_task_messages
|
||||
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;
|
||||
}
|
||||
// 按 topic_id 移除处理状态,不论当前选中哪个话题。
|
||||
|
||||
@ -3,8 +3,49 @@ import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@ -303,6 +303,9 @@ export interface ExecutionCompleted {
|
||||
topic_id?: string;
|
||||
timestamp?: number;
|
||||
subagent_task_id?: string;
|
||||
/** 子代理最终状态(completed/failed/timeout/cancelled/interrupted) */
|
||||
subagent_status?: string;
|
||||
subagent_summary?: string;
|
||||
}
|
||||
|
||||
export type WsOutbound =
|
||||
@ -504,7 +507,15 @@ export interface ChatMessage {
|
||||
|
||||
/** task 工具返回的 JSON 结构 */
|
||||
export interface TaskToolResult {
|
||||
status: 'success' | 'failed' | 'timeout';
|
||||
// status 值由后端 TaskToolResult.status 决定,包括:
|
||||
// - running: 异步子代理刚 spawn 的占位结果
|
||||
// - success: 子代理执行成功(旧)
|
||||
// - completed: 子代理执行成功(新,与 SubagentStatus 对齐)
|
||||
// - failed: 子代理执行失败
|
||||
// - timeout: 子代理执行超时
|
||||
// - cancelled: 子代理被用户取消
|
||||
// - interrupted: 子代理因服务器重启被中断
|
||||
status: string;
|
||||
summary: string;
|
||||
output: string;
|
||||
task_id: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user