feat(web): auto-scroll on all state changes + completed agent badges auto-fade

Fix auto-scroll by adding missing dependencies (currentConversationId, isStreaming,
completedAgents). Completed agent badges now show for 2.5s then smoothly fade out
instead of accumulating, keeping the status area clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-07 09:12:32 -08:00
parent af8aea6b03
commit 636b10b733
3 changed files with 84 additions and 9 deletions

View File

@ -8,7 +8,7 @@ import { MessageSquare, Menu } from 'lucide-react';
export function ChatWindow() {
const messagesEndRef = useRef<HTMLDivElement>(null);
const { messages, currentConversationId, isStreaming, streamContent, sidebarOpen, toggleSidebar, activeAgents, coordinatorPhase } = useChatStore();
const { messages, currentConversationId, isStreaming, streamContent, sidebarOpen, toggleSidebar, activeAgents, completedAgents, coordinatorPhase } = useChatStore();
const {
sendMessage,
pendingFiles,
@ -18,10 +18,10 @@ export function ChatWindow() {
removeFile,
} = useChat();
// Auto-scroll to bottom (including when agent status changes)
// Auto-scroll to bottom on any content change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamContent, activeAgents, coordinatorPhase]);
}, [messages, streamContent, isStreaming, activeAgents, completedAgents, coordinatorPhase, currentConversationId]);
const currentMessages = currentConversationId
? messages[currentConversationId] || []

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { Bot, Loader2, CheckCircle2, Brain } from 'lucide-react';
import { clsx } from 'clsx';
import { useChatStore } from '../stores/chatStore';
import { useChatStore, CompletedAgent } from '../stores/chatStore';
const AGENT_DISPLAY_NAMES: Record<string, string> = {
policy_expert: '政策专家',
@ -18,8 +19,65 @@ const PHASE_DISPLAY: Record<string, string> = {
evaluating: '正在进行质量检查...',
};
const BADGE_VISIBLE_MS = 2500; // Show completed badge for 2.5s
const BADGE_FADE_MS = 500; // Then fade out over 0.5s
export function TypingIndicator() {
const { activeAgents, completedAgents, coordinatorPhase, coordinatorMessage } = useChatStore();
const [visibleCompleted, setVisibleCompleted] = useState<(CompletedAgent & { fading?: boolean })[]>([]);
// Track completed agents: show briefly then auto-fade
useEffect(() => {
if (completedAgents.length === 0) {
setVisibleCompleted([]);
return;
}
// Add newly completed agents
setVisibleCompleted(prev => {
const prevKeys = new Set(prev.map(a => `${a.messageId}-${a.agentType}`));
const newAgents = completedAgents.filter(
a => !prevKeys.has(`${a.messageId}-${a.agentType}`),
);
if (newAgents.length === 0) return prev;
return [...prev, ...newAgents.map(a => ({ ...a, fading: false }))];
});
}, [completedAgents]);
// Auto-fade completed agents after BADGE_VISIBLE_MS
useEffect(() => {
const nonFading = visibleCompleted.filter(a => !a.fading);
if (nonFading.length === 0) return;
const oldest = Math.min(...nonFading.map(a => a.completedAt));
const elapsed = Date.now() - oldest;
const delay = Math.max(0, BADGE_VISIBLE_MS - elapsed);
const fadeTimer = setTimeout(() => {
setVisibleCompleted(prev =>
prev.map(a => {
if (!a.fading && Date.now() - a.completedAt >= BADGE_VISIBLE_MS) {
return { ...a, fading: true };
}
return a;
}),
);
}, delay);
return () => clearTimeout(fadeTimer);
}, [visibleCompleted]);
// Remove fully faded agents after animation completes
useEffect(() => {
const fading = visibleCompleted.filter(a => a.fading);
if (fading.length === 0) return;
const removeTimer = setTimeout(() => {
setVisibleCompleted(prev => prev.filter(a => !a.fading));
}, BADGE_FADE_MS);
return () => clearTimeout(removeTimer);
}, [visibleCompleted]);
const hasAgentActivity = activeAgents.length > 0 || coordinatorPhase !== null;
@ -42,7 +100,7 @@ export function TypingIndicator() {
</div>
{/* Agent status — shown below dots when there's activity */}
{hasAgentActivity && (
{(hasAgentActivity || visibleCompleted.length > 0) && (
<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 && (
@ -72,17 +130,18 @@ export function TypingIndicator() {
</div>
))}
{/* Completed agents (compact badges) */}
{completedAgents.length > 0 && activeAgents.length > 0 && (
{/* Completed agents (auto-fading badges) */}
{visibleCompleted.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1 border-t border-secondary-200">
{completedAgents.map((agent) => (
{visibleCompleted.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',
'inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full transition-opacity',
agent.success
? 'bg-green-50 text-green-600'
: 'bg-red-50 text-red-500',
agent.fading ? 'agent-badge-fade' : 'opacity-100',
)}
>
<CheckCircle2 className="w-3 h-3" />

View File

@ -138,3 +138,19 @@
.agent-status-enter {
animation: fadeIn 0.3s ease-out;
}
/* Completed agent badge fade-out */
@keyframes badgeFadeOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.8);
}
}
.agent-badge-fade {
animation: badgeFadeOut 0.5s ease-out forwards;
}