feat(desktop): DeerFlow visual redesign + stream hang fix + intelligence client
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

DeerFlow frontend visual overhaul:
- Card-style input box (white rounded card, textarea top, actions bottom)
- Dropdown mode selector (闪速/思考/Pro/Ultra with icons+descriptions)
- Colored quick-action chips (小惊喜/写作/研究/收集/学习)
- Minimal top bar (title + token count + export)
- Warm gray color system (#faf9f6 bg, #f5f4f1 sidebar, #e8e6e1 border)
- DeerFlow-style sidebar (新对话/对话/智能体 nav)
- Reasoning block, tool call chain, task progress visualization
- Streaming text, model selector, suggestion chips components
- Resizable artifact panel with drag handle
- Virtualized message list for 100+ messages

Bug fixes:
- Stream hang: GatewayClient onclose code 1000 now calls onComplete
- WebView2 textarea border: CSS !important override for UA styles
- Gateway stream event handling (response/phase/tool_call types)

Intelligence client:
- Unified client with fallback drivers (compactor/heartbeat/identity/memory/reflection)
- Gateway API types and type conversions
This commit is contained in:
iven
2026-04-01 22:03:07 +08:00
parent e3b93ff96d
commit 73ff5e8c5e
43 changed files with 4817 additions and 905 deletions

View File

@@ -5,16 +5,27 @@ import { useChatStore, Message } from '../store/chatStore';
import { useConnectionStore } from '../store/connectionStore';
import { useAgentStore } from '../store/agentStore';
import { useConfigStore } from '../store/configStore';
import { Paperclip, ChevronDown, Terminal, SquarePen, ArrowUp, MessageSquare, Download, Copy, Check } from 'lucide-react';
import { Paperclip, SquarePen, ArrowUp, MessageSquare, Download, X, FileText, Image as ImageIcon } from 'lucide-react';
import { Button, EmptyState, MessageListSkeleton, LoadingDots } from './ui';
import { ResizableChatLayout } from './ai/ResizableChatLayout';
import { ArtifactPanel } from './ai/ArtifactPanel';
import { ToolCallChain } from './ai/ToolCallChain';
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
import { FirstConversationPrompt } from './FirstConversationPrompt';
import { MessageSearch } from './MessageSearch';
// MessageSearch temporarily removed during DeerFlow redesign
import { OfflineIndicator } from './OfflineIndicator';
import {
useVirtualizedMessages,
type VirtualizedMessageItem
} from '../lib/message-virtualization';
import { Conversation } from './ai/Conversation';
import { ReasoningBlock } from './ai/ReasoningBlock';
import { StreamingText } from './ai/StreamingText';
import { ChatMode } from './ai/ChatMode';
import { ModelSelector } from './ai/ModelSelector';
import { TaskProgress } from './ai/TaskProgress';
import { SuggestionChips } from './ai/SuggestionChips';
// TokenMeter temporarily unused — using inline text counter instead
// Default heights for virtualized messages
const DEFAULT_MESSAGE_HEIGHTS: Record<string, number> = {
@@ -33,17 +44,21 @@ export function ChatArea() {
const {
messages, currentAgent, isStreaming, isLoading, currentModel,
sendMessage: sendToGateway, setCurrentModel, initStreamListener,
newConversation,
newConversation, chatMode, setChatMode, suggestions,
artifacts, selectedArtifactId, artifactPanelOpen,
selectArtifact, setArtifactPanelOpen,
totalInputTokens, totalOutputTokens,
} = useChatStore();
const connectionState = useConnectionStore((s) => s.connectionState);
const clones = useAgentStore((s) => s.clones);
const models = useConfigStore((s) => s.models);
const [input, setInput] = useState('');
const [showModelPicker, setShowModelPicker] = useState(false);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const fileInputRef = useRef<HTMLInputElement>(null);
// Convert messages to virtualization format
const virtualizedMessages: VirtualizedMessageItem[] = useMemo(
@@ -90,6 +105,41 @@ export function ChatArea() {
}
}, []);
// File handling
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_FILES = 5;
const addFiles = useCallback((files: FileList | File[]) => {
const incoming = Array.from(files).filter((f) => f.size <= MAX_FILE_SIZE);
setPendingFiles((prev) => {
const combined = [...prev, ...incoming];
return combined.slice(0, MAX_FILES);
});
}, []);
// Paste handler for images/files
useEffect(() => {
const handler = (e: ClipboardEvent) => {
if (e.clipboardData?.files.length) {
e.preventDefault();
addFiles(e.clipboardData.files);
}
};
document.addEventListener('paste', handler);
return () => document.removeEventListener('paste', handler);
}, [addFiles]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (e.dataTransfer.files.length) {
addFiles(e.dataTransfer.files);
}
}, [addFiles]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
// Init agent stream listener on mount
useEffect(() => {
const unsub = initStreamListener();
@@ -106,10 +156,14 @@ export function ChatArea() {
}, [messages, useVirtualization, scrollToBottom]);
const handleSend = () => {
if (!input.trim() || isStreaming) return;
// Allow sending in offline mode - message will be queued
sendToGateway(input);
if ((!input.trim() && pendingFiles.length === 0) || isStreaming) return;
// Attach file names as metadata in the message
const fileContext = pendingFiles.length > 0
? `\n\n[附件: ${pendingFiles.map((f) => f.name).join(', ')}]`
: '';
sendToGateway(input + fileContext);
setInput('');
setPendingFiles([]);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -121,52 +175,73 @@ export function ChatArea() {
const connected = connectionState === 'connected';
// Navigate to a specific message by ID
const handleNavigateToMessage = useCallback((messageId: string) => {
const messageEl = messageRefs.current.get(messageId);
if (messageEl && scrollRef.current) {
messageEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Add highlight effect
messageEl.classList.add('ring-2', 'ring-orange-400', 'ring-offset-2');
setTimeout(() => {
messageEl.classList.remove('ring-2', 'ring-orange-400', 'ring-offset-2');
}, 2000);
// Export current conversation as Markdown
const exportCurrentConversation = () => {
const title = currentAgent?.name || 'ZCLAW 对话';
const lines = [`# ${title}`, '', `导出时间: ${new Date().toLocaleString('zh-CN')}`, ''];
for (const msg of messages) {
const label = msg.role === 'user' ? '用户' : msg.role === 'assistant' ? '助手' : msg.role;
lines.push(`## ${label}`, '', msg.content, '');
}
}, []);
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title.replace(/[/\\?%*:|"<>]/g, '_')}.md`;
a.click();
URL.revokeObjectURL(url);
};
// Build artifact panel content
const artifactRightPanel = (
<ArtifactPanel
artifacts={artifacts}
selectedId={selectedArtifactId}
onSelect={selectArtifact}
onClose={() => setArtifactPanelOpen(false)}
/>
);
return (
<div className="flex flex-col h-full">
{/* Header */}
{/* Header */}
<div className="h-14 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between px-6 flex-shrink-0 bg-white dark:bg-gray-900">
<div className="flex items-center gap-2">
<h2 className="font-semibold text-gray-900 dark:text-gray-100">{currentAgent?.name || 'ZCLAW'}</h2>
{isStreaming ? (
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-gray-500 dark:bg-gray-400 rounded-full thinking-dot"></span>
</span>
) : (
<span className={`text-xs flex items-center gap-1 ${connected ? 'text-green-500' : 'text-gray-500 dark:text-gray-400'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-400' : 'bg-gray-300 dark:bg-gray-600'}`}></span>
{connected ? 'Gateway 已连接' : 'Gateway 未连接'}
</span>
)}
<ResizableChatLayout
chatPanel={
<div className="flex flex-col h-full">
{/* Header — DeerFlow-style: minimal */}
<div className="h-14 border-b border-transparent flex items-center justify-between px-6 flex-shrink-0 bg-white dark:bg-gray-900">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>{currentAgent?.name || '新对话'}</span>
</div>
<div className="flex items-center gap-2">
{/* Offline indicator in header */}
<div className="flex items-center gap-4">
{/* Token usage counter — DeerFlow-style plain text */}
{(totalInputTokens + totalOutputTokens) > 0 && (() => {
const total = totalInputTokens + totalOutputTokens;
const display = total >= 1000 ? `${(total / 1000).toFixed(1)}K` : String(total);
return (
<span className="text-sm text-gray-500 flex items-center gap-1.5">
{display}
</span>
);
})()}
<OfflineIndicator compact />
{messages.length > 0 && (
<MessageSearch onNavigateToMessage={handleNavigateToMessage} />
<Button
variant="ghost"
size="sm"
onClick={exportCurrentConversation}
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="导出对话"
>
<Download className="w-3.5 h-3.5" />
<span className="text-sm"></span>
</Button>
)}
{messages.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={newConversation}
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="新对话"
aria-label="开始新对话"
className="flex items-center gap-1.5 text-gray-500 dark:text-gray-400 hover:text-orange-600 dark:hover:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20"
>
<SquarePen className="w-3.5 h-3.5" />
@@ -176,7 +251,7 @@ export function ChatArea() {
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar bg-white dark:bg-gray-900">
<Conversation className="flex-1 bg-white dark:bg-gray-900">
<AnimatePresence mode="popLayout">
{/* Loading skeleton */}
{isLoading && messages.length === 0 && (
@@ -240,21 +315,60 @@ export function ChatArea() {
))
)}
</AnimatePresence>
</div>
</Conversation>
{/* Input */}
<div className="border-t border-gray-100 dark:border-gray-800 p-4 bg-white dark:bg-gray-900">
<div className="p-4 bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto">
<div className="relative flex items-end gap-2 bg-gray-50 dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 p-2 focus-within:border-orange-300 dark:focus-within:border-orange-600 focus-within:ring-2 focus-within:ring-orange-100 dark:focus-within:ring-orange-900/30 transition-all">
<Button
variant="ghost"
size="sm"
className="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="添加附件"
>
<Paperclip className="w-5 h-5" />
</Button>
<div className="flex-1 py-1">
{/* Suggestion chips */}
{!isStreaming && suggestions.length > 0 && (
<SuggestionChips
suggestions={suggestions}
onSelect={(text) => { setInput(text); textareaRef.current?.focus(); }}
className="mb-3"
/>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
/>
{/* Pending file previews */}
{pendingFiles.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{pendingFiles.map((file, idx) => (
<div
key={`${file.name}-${idx}`}
className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-700 rounded-lg text-xs text-gray-700 dark:text-gray-300 max-w-[200px]"
>
{file.type.startsWith('image/') ? (
<ImageIcon className="w-3.5 h-3.5 flex-shrink-0 text-orange-500" />
) : (
<FileText className="w-3.5 h-3.5 flex-shrink-0 text-gray-500" />
)}
<span className="truncate">{file.name}</span>
<span className="text-gray-400 flex-shrink-0">({(file.size / 1024).toFixed(0)}K)</span>
<button
onClick={() => setPendingFiles((prev) => prev.filter((_, i) => i !== idx))}
className="p-0.5 text-gray-400 hover:text-red-500 flex-shrink-0"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
{/* Input card — DeerFlow-style: white card, textarea top, actions bottom */}
<div
className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm transition-all"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* Textarea area */}
<div className="px-4 pt-4 pb-1">
<textarea
ref={textareaRef}
value={input}
@@ -263,277 +377,70 @@ export function ChatArea() {
placeholder={
isStreaming
? 'Agent 正在回复...'
: `发送给 ${currentAgent?.name || 'ZCLAW'}${!connected ? ' (离线模式)' : ''}`
: '今天我能为你做些什么?'
}
disabled={isStreaming}
rows={1}
className="w-full bg-transparent border-none focus:outline-none text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none leading-relaxed mt-1"
style={{ minHeight: '24px', maxHeight: '160px' }}
rows={2}
className="w-full bg-transparent border-none outline-none ring-0 focus:outline-none focus:ring-0 text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none leading-relaxed"
style={{ minHeight: '48px', maxHeight: '160px', border: 'none', outline: 'none', boxShadow: 'none' }}
/>
</div>
<div className="flex items-center gap-2 pr-2 pb-1 relative">
<Button
variant="ghost"
size="sm"
onClick={() => setShowModelPicker(!showModelPicker)}
className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
aria-label="选择模型"
aria-expanded={showModelPicker}
>
<span>{currentModel}</span>
<ChevronDown className="w-3 h-3" />
</Button>
{showModelPicker && (
<div className="absolute bottom-full right-8 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 min-w-[160px] max-h-48 overflow-y-auto z-10">
{models.length > 0 ? (
models.map((model) => (
<button
key={model.id}
onClick={() => { setCurrentModel(model.id); setShowModelPicker(false); }}
className={`w-full text-left px-3 py-2 text-xs hover:bg-gray-50 dark:hover:bg-gray-700 ${model.id === currentModel ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-700 dark:text-gray-300'}`}
>
{model.name}
</button>
))
) : (
<div className="px-3 py-2 text-xs text-gray-400">
{connected ? '加载中...' : '未连接 Gateway'}
</div>
)}
</div>
)}
<Button
variant="primary"
size="sm"
onClick={handleSend}
disabled={isStreaming || !input.trim()}
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white disabled:opacity-50"
aria-label="发送消息"
>
<ArrowUp className="w-4 h-4 text-white" />
</Button>
{/* Bottom action bar */}
<div className="flex items-center justify-between px-3 pb-3">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
aria-label="添加附件"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip className="w-5 h-5" />
</Button>
<ChatMode
value={chatMode}
onChange={setChatMode}
disabled={isStreaming}
/>
</div>
<div className="flex items-center gap-2">
<ModelSelector
models={models.map(m => ({ id: m.id, name: m.name, provider: m.provider }))}
currentModel={currentModel}
onSelect={setCurrentModel}
disabled={isStreaming}
/>
<Button
variant="primary"
size="sm"
onClick={handleSend}
disabled={isStreaming || (!input.trim() && pendingFiles.length === 0)}
className="w-8 h-8 rounded-full p-0 flex items-center justify-center bg-orange-500 hover:bg-orange-600 text-white disabled:opacity-50"
aria-label="发送消息"
>
<ArrowUp className="w-4 h-4 text-white" />
</Button>
</div>
</div>
</div>
<div className="text-center mt-2 text-xs text-gray-500 dark:text-gray-400">
Agent AI
</div>
</div>
</div>
</div>
);
}
/** Code block with copy and download functionality */
function CodeBlock({ code, language, index }: { code: string; language: string; index: number }) {
const [copied, setCopied] = useState(false);
const [downloading, setDownloading] = useState(false);
// Infer filename from language or content
const inferFilename = (): string => {
const extMap: Record<string, string> = {
javascript: 'js', typescript: 'ts', python: 'py', rust: 'rs',
go: 'go', java: 'java', cpp: 'cpp', c: 'c', csharp: 'cs',
html: 'html', css: 'css', scss: 'scss', json: 'json',
yaml: 'yaml', yml: 'yaml', xml: 'xml', sql: 'sql',
shell: 'sh', bash: 'sh', powershell: 'ps1',
markdown: 'md', md: 'md', dockerfile: 'dockerfile',
};
// Check if language contains a filename (e.g., ```app.tsx)
if (language.includes('.') || language.includes('/')) {
return language;
}
// Check for common patterns in code
const codeLower = code.toLowerCase();
if (codeLower.includes('<!doctype html') || codeLower.includes('<html')) {
return 'index.html';
}
if (codeLower.includes('package.json') || (codeLower.includes('"name"') && codeLower.includes('"version"'))) {
return 'package.json';
}
if (codeLower.startsWith('{') && (codeLower.includes('"import"') || codeLower.includes('"export"'))) {
return 'config.json';
}
// Use language extension
const ext = extMap[language.toLowerCase()] || language.toLowerCase();
return `code-${index + 1}.${ext || 'txt'}`;
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const handleDownload = () => {
setDownloading(true);
try {
const filename = inferFilename();
const blob = new Blob([code], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error('Failed to download:', err);
}
setTimeout(() => setDownloading(false), 500);
};
return (
<div className="relative group my-2">
<pre className="bg-gray-900 text-gray-100 rounded-lg p-3 overflow-x-auto text-xs font-mono leading-relaxed">
{language && (
<div className="text-gray-500 text-[10px] mb-1 uppercase flex items-center justify-between">
<span>{language}</span>
</div>
)}
<code>{code}</code>
</pre>
{/* Action buttons - show on hover */}
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={handleCopy}
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300 hover:text-white transition-colors"
title="复制代码"
>
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
</button>
<button
onClick={handleDownload}
className="p-1.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300 hover:text-white transition-colors"
title="下载文件"
disabled={downloading}
>
<Download className={`w-3.5 h-3.5 ${downloading ? 'animate-pulse' : ''}`} />
</button>
</div>
</div>
);
}
/** Lightweight markdown renderer — handles code blocks, inline code, bold, italic, links */
function sanitizeUrl(url: string): string {
const safeProtocols = ['http:', 'https:', 'mailto:'];
try {
const parsed = new URL(url, window.location.origin);
if (safeProtocols.includes(parsed.protocol)) {
return parsed.href;
}
} catch {
// Invalid URL
}
return '#';
}
function renderMarkdown(text: string): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
const lines = text.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Fenced code block
if (line.startsWith('```')) {
const lang = line.slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing ```
nodes.push(
<CodeBlock key={nodes.length} code={codeLines.join('\n')} language={lang} index={nodes.length} />
);
continue;
}
// Normal line — parse inline markdown
nodes.push(
<span key={nodes.length}>
{i > 0 && lines[i - 1] !== undefined && !nodes[nodes.length - 1]?.toString().includes('pre') && '\n'}
{renderInline(line)}
</span>
);
i++;
}
return nodes;
}
function renderInline(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
// Pattern: **bold**, *italic*, `code`, [text](url)
const regex = /(\*\*(.+?)\*\*)|(\*(.+?)\*)|(`(.+?)`)|(\[(.+?)\]\((.+?)\))/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// Text before match
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
if (match[1]) {
// **bold**
parts.push(<strong key={parts.length} className="font-semibold">{match[2]}</strong>);
} else if (match[3]) {
// *italic*
parts.push(<em key={parts.length}>{match[4]}</em>);
} else if (match[5]) {
// `code`
parts.push(
<code key={parts.length} className="bg-gray-100 dark:bg-gray-700 text-orange-700 dark:text-orange-400 px-1 py-0.5 rounded text-[0.85em] font-mono">
{match[6]}
</code>
);
} else if (match[7]) {
// [text](url) - 使用 sanitizeUrl 防止 XSS
parts.push(
<a key={parts.length} href={sanitizeUrl(match[9])} target="_blank" rel="noopener noreferrer"
className="text-orange-600 dark:text-orange-400 underline hover:text-orange-700 dark:hover:text-orange-300">{match[8]}</a>
);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : [text];
rightPanel={artifactRightPanel}
rightPanelTitle="产物"
rightPanelOpen={artifactPanelOpen}
onRightPanelToggle={setArtifactPanelOpen}
/>
);
}
function MessageBubble({ message }: { message: Message }) {
// Tool messages are now absorbed into the assistant message's toolSteps chain.
// Legacy standalone tool messages (from older sessions) still render as before.
if (message.role === 'tool') {
return (
<div className="ml-12 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 text-xs font-mono">
<div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 mb-1">
<Terminal className="w-3.5 h-3.5" />
<span className="font-semibold">{message.toolName || 'tool'}</span>
</div>
{message.toolInput && (
<pre className="text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-900 rounded p-2 mb-1 overflow-x-auto">{message.toolInput}</pre>
)}
{message.content && (
<pre className="text-green-700 dark:text-green-400 bg-white dark:bg-gray-900 rounded p-2 overflow-x-auto">{message.content}</pre>
)}
</div>
);
return null;
}
const isUser = message.role === 'user';
@@ -573,11 +480,42 @@ function MessageBubble({ message }: { message: Message }) {
</div>
) : (
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'} relative group`}>
<div className={`leading-relaxed whitespace-pre-wrap ${isUser ? 'text-white' : 'text-gray-700 dark:text-gray-200'}`}>
{/* Optimistic sending indicator */}
{isUser && message.optimistic && (
<span className="text-xs text-blue-200 dark:text-blue-300 mb-1 block animate-pulse">
Sending...
</span>
)}
{/* Reasoning block for thinking content (DeerFlow-inspired) */}
{!isUser && message.thinkingContent && (
<ReasoningBlock
content={message.thinkingContent}
isStreaming={message.streaming}
/>
)}
{/* Tool call steps chain (DeerFlow-inspired) */}
{!isUser && message.toolSteps && message.toolSteps.length > 0 && (
<ToolCallChain
steps={message.toolSteps}
isStreaming={message.streaming}
/>
)}
{/* Subtask tracking (DeerFlow-inspired) */}
{!isUser && message.subtasks && message.subtasks.length > 0 && (
<TaskProgress tasks={message.subtasks} className="mb-3" />
)}
{/* Message content with streaming support */}
<div className={`leading-relaxed ${isUser ? 'text-white whitespace-pre-wrap' : 'text-gray-700 dark:text-gray-200'}`}>
{message.content
? (isUser ? message.content : renderMarkdown(message.content))
? (isUser
? message.content
: <StreamingText
content={message.content}
isStreaming={!!message.streaming}
className="text-gray-700 dark:text-gray-200"
/>
)
: '...'}
{message.streaming && <span className="inline-block w-1.5 h-4 bg-orange-500 animate-pulse ml-0.5 align-text-bottom rounded-sm" />}
</div>
{message.error && (
<p className="text-xs text-red-500 mt-2">{message.error}</p>