feat(web): move agent status inline with typing indicator for better UX

Instead of showing agent status in a separate panel below the chat,
display it inline beneath the typing dots ("...") in the message flow.
The dots remain the primary waiting indicator; agent status appears
below as supplementary context during specialist agent invocations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-07 09:05:41 -08:00
parent 40a0513b05
commit af8aea6b03
2 changed files with 94 additions and 13 deletions

View File

@ -2,14 +2,13 @@ import { useRef, useEffect } from 'react';
import { MessageBubble } from './MessageBubble';
import { InputArea } from './InputArea';
import { TypingIndicator } from './TypingIndicator';
import { AgentStatusIndicator } from './AgentStatusIndicator';
import { useChatStore } from '../stores/chatStore';
import { useChat } from '../hooks/useChat';
import { MessageSquare, Menu } from 'lucide-react';
export function ChatWindow() {
const messagesEndRef = useRef<HTMLDivElement>(null);
const { messages, currentConversationId, isStreaming, streamContent, sidebarOpen, toggleSidebar } = useChatStore();
const { messages, currentConversationId, isStreaming, streamContent, sidebarOpen, toggleSidebar, activeAgents, coordinatorPhase } = useChatStore();
const {
sendMessage,
pendingFiles,
@ -19,10 +18,10 @@ export function ChatWindow() {
removeFile,
} = useChat();
// Auto-scroll to bottom
// Auto-scroll to bottom (including when agent status changes)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamContent]);
}, [messages, streamContent, activeAgents, coordinatorPhase]);
const currentMessages = currentConversationId
? messages[currentConversationId] || []
@ -81,9 +80,8 @@ export function ChatWindow() {
)}
</div>
{/* Agent status + Input area */}
{/* Input area */}
<div className="border-t border-secondary-200 bg-white p-4">
<AgentStatusIndicator />
<div className="max-w-3xl mx-auto">
<InputArea
onSend={sendMessage}

View File

@ -1,6 +1,28 @@
import { Bot } from 'lucide-react';
import { Bot, Loader2, CheckCircle2, Brain } from 'lucide-react';
import { clsx } from 'clsx';
import { useChatStore } from '../stores/chatStore';
const AGENT_DISPLAY_NAMES: Record<string, string> = {
policy_expert: '政策专家',
assessment_expert: '评估专家',
strategist: '策略顾问',
objection_handler: '异议处理专家',
case_analyst: '案例分析师',
memory_manager: '记忆管理',
};
const PHASE_DISPLAY: Record<string, string> = {
analyzing: '正在分析问题...',
orchestrating: '正在协调专家团队...',
synthesizing: '正在综合分析结果...',
evaluating: '正在进行质量检查...',
};
export function TypingIndicator() {
const { activeAgents, completedAgents, coordinatorPhase, coordinatorMessage } = useChatStore();
const hasAgentActivity = activeAgents.length > 0 || coordinatorPhase !== null;
return (
<div className="flex gap-3 message-enter">
{/* Avatar */}
@ -8,13 +30,74 @@ export function TypingIndicator() {
<Bot className="w-4 h-4 text-secondary-600" />
</div>
{/* Typing dots */}
<div className="bg-secondary-100 rounded-2xl rounded-tl-sm px-4 py-3">
<div className="flex items-center gap-1">
<span className="w-2 h-2 bg-secondary-400 rounded-full typing-dot" />
<span className="w-2 h-2 bg-secondary-400 rounded-full typing-dot" />
<span className="w-2 h-2 bg-secondary-400 rounded-full typing-dot" />
{/* Content area */}
<div className="space-y-2 max-w-[80%]">
{/* Typing dots — always shown as primary indicator */}
<div className="bg-secondary-100 rounded-2xl rounded-tl-sm px-4 py-3">
<div className="flex items-center gap-1">
<span className="w-2 h-2 bg-secondary-400 rounded-full typing-dot" />
<span className="w-2 h-2 bg-secondary-400 rounded-full typing-dot" />
<span className="w-2 h-2 bg-secondary-400 rounded-full typing-dot" />
</div>
</div>
{/* Agent status — shown below dots when there's activity */}
{hasAgentActivity && (
<div className="agent-status-enter rounded-xl bg-secondary-50 border border-secondary-200 px-3 py-2.5 space-y-1.5">
{/* Coordinator phase */}
{coordinatorPhase && (
<div className="flex items-center gap-2 text-xs text-secondary-600">
<Brain className="w-3.5 h-3.5 text-primary-500 agent-pulse flex-shrink-0" />
<span className="text-primary-600 font-medium">
{PHASE_DISPLAY[coordinatorPhase] || coordinatorMessage}
</span>
</div>
)}
{/* Active agents */}
{activeAgents.map((agent) => (
<div
key={`${agent.messageId}-${agent.agentType}`}
className="agent-enter flex items-center gap-2 text-xs"
>
<Loader2 className="w-3.5 h-3.5 text-primary-500 animate-spin flex-shrink-0" />
<span className="text-secondary-700">
<span className="font-medium">
{AGENT_DISPLAY_NAMES[agent.agentType] || agent.agentName}
</span>
{agent.description && (
<span className="text-secondary-500 ml-1">- {agent.description}</span>
)}
</span>
</div>
))}
{/* Completed agents (compact badges) */}
{completedAgents.length > 0 && activeAgents.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1 border-t border-secondary-200">
{completedAgents.map((agent) => (
<span
key={`${agent.messageId}-${agent.agentType}-done`}
className={clsx(
'inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full',
agent.success
? 'bg-green-50 text-green-600'
: 'bg-red-50 text-red-500',
)}
>
<CheckCircle2 className="w-3 h-3" />
{AGENT_DISPLAY_NAMES[agent.agentType] || agent.agentName}
{agent.durationMs > 0 && (
<span className="text-secondary-400">
{(agent.durationMs / 1000).toFixed(1)}s
</span>
)}
</span>
))}
</div>
)}
</div>
)}
</div>
</div>
);