Files
zclaw_openfang/desktop/src/components/ChatArea.tsx
iven 5c74e74f2a fix(desktop): component cleanup + dead code removal + DeerFlow ai-elements
- ChatArea: DeerFlow ai-elements annotations for accessibility
- Conversation: remove unused Context, simplify message rendering
- Delete dead modules: audit-logger.ts, gateway-reconnect.ts
- Replace console.log with structured logger across components
- Add idb dependency for IndexedDB persistence
- Fix kernel-skills type safety improvements
2026-04-03 00:28:58 +08:00

763 lines
28 KiB
TypeScript

import { useState, useEffect, useRef, useCallback, useMemo, type MutableRefObject, type RefObject, type CSSProperties } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { List, type ListImperativeAPI } from 'react-window';
import { useChatStore, type Message } from '../store/chatStore';
import { useConversationStore } from '../store/chat/conversationStore';
import { useArtifactStore } from '../store/chat/artifactStore';
import { useConnectionStore } from '../store/connectionStore';
import { useAgentStore } from '../store/agentStore';
import { useConfigStore } from '../store/configStore';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
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 { ClassroomPlayer } from './classroom_player';
import { useClassroomStore } from '../store/classroomStore';
// 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> = {
user: 80,
assistant: 150,
tool: 120,
hand: 120,
workflow: 100,
system: 60,
};
// Threshold for enabling virtualization (messages count)
const VIRTUALIZATION_THRESHOLD = 100;
export function ChatArea() {
const {
messages, isStreaming, isLoading,
sendMessage: sendToGateway, initStreamListener,
newConversation, chatMode, setChatMode, suggestions,
totalInputTokens, totalOutputTokens,
} = useChatStore();
const currentAgent = useConversationStore((s) => s.currentAgent);
const currentModel = useConversationStore((s) => s.currentModel);
const setCurrentModel = useConversationStore((s) => s.setCurrentModel);
const {
artifacts, selectedArtifactId, artifactPanelOpen,
selectArtifact, setArtifactPanelOpen,
} = useArtifactStore();
const connectionState = useConnectionStore((s) => s.connectionState);
const { activeClassroom, classroomOpen, closeClassroom, generating, progressPercent, progressActivity, error: classroomError, clearError: clearClassroomError } = useClassroomStore();
const clones = useAgentStore((s) => s.clones);
const models = useConfigStore((s) => s.models);
const [input, setInput] = useState('');
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(
() => messages.map((msg) => ({
id: msg.id,
height: DEFAULT_MESSAGE_HEIGHTS[msg.role] ?? 100,
role: msg.role,
})),
[messages]
);
// Use virtualization hook
const {
listRef,
getHeight,
setHeight,
scrollToBottom,
} = useVirtualizedMessages(virtualizedMessages, DEFAULT_MESSAGE_HEIGHTS);
// Whether to use virtualization
const useVirtualization = messages.length >= VIRTUALIZATION_THRESHOLD;
// Get current clone for first conversation prompt
const currentClone = useMemo(() => {
if (!currentAgent) return null;
return clones.find((c) => c.id === currentAgent.id) || null;
}, [currentAgent, clones]);
// Check if should show first conversation prompt
const showFirstPrompt = messages.length === 0 && currentClone && !currentClone.onboardingCompleted;
// Handle suggestion click from first conversation prompt
const handleSelectSuggestion = (text: string) => {
setInput(text);
textareaRef.current?.focus();
};
// Auto-resize textarea
const adjustTextarea = useCallback(() => {
const el = textareaRef.current;
if (el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
}
}, []);
// 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();
return unsub;
}, []);
// Listen for hand-execution-complete Tauri events
useEffect(() => {
let unlisten: UnlistenFn | undefined;
listen<{ approvalId: string; handId: string; success: boolean; error?: string | null }>(
'hand-execution-complete',
(event) => {
const { handId, success, error } = event.payload;
useChatStore.getState().addMessage({
id: crypto.randomUUID(),
role: 'hand',
content: success
? `Hand ${handId} 执行完成`
: `Hand ${handId} 执行失败: ${error || '未知错误'}`,
timestamp: new Date(),
handName: handId,
handStatus: success ? 'completed' : 'failed',
handResult: event.payload,
});
},
).then((fn) => { unlisten = fn; });
return () => { unlisten?.(); };
}, []);
// Auto-scroll to bottom on new messages
useEffect(() => {
if (scrollRef.current && !useVirtualization) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} else if (useVirtualization && messages.length > 0) {
scrollToBottom();
}
}, [messages, useVirtualization, scrollToBottom]);
const handleSend = () => {
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) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const connected = connectionState === 'connected';
// 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="relative h-full">
{/* Generation progress overlay */}
<AnimatePresence>
{generating && (
<motion.div
key="generation-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-40 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm flex items-center justify-center"
>
<div className="text-center space-y-4">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-500 rounded-full animate-spin mx-auto" />
<div>
<p className="text-lg font-medium text-gray-900 dark:text-white">
...
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{progressActivity || '准备中...'}
</p>
</div>
{progressPercent > 0 && (
<div className="w-64 mx-auto">
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-1">{progressPercent}%</p>
</div>
)}
<button
onClick={() => useClassroomStore.getState().cancelGeneration()}
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg"
>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ClassroomPlayer overlay */}
<AnimatePresence>
{classroomOpen && activeClassroom && (
<motion.div
key="classroom-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-50 bg-white dark:bg-gray-900"
>
<ClassroomPlayer
onClose={closeClassroom}
/>
</motion.div>
)}
</AnimatePresence>
<ResizableChatLayout
chatPanel={
<div className="flex flex-col h-full">
{/* Classroom generation error banner */}
{classroomError && (
<div className="mx-4 mt-2 px-4 py-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center justify-between text-sm">
<span className="text-red-600 dark:text-red-400">: {classroomError}</span>
<button onClick={clearClassroomError} className="text-red-400 hover:text-red-600 ml-3 text-xs"></button>
</div>
)}
{/* 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-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 && (
<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="新对话"
>
<SquarePen className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
{/* Messages */}
<Conversation className="flex-1 bg-white dark:bg-gray-900">
<AnimatePresence mode="popLayout">
{/* Loading skeleton */}
{isLoading && messages.length === 0 && (
<motion.div
key="loading-skeleton"
variants={fadeInVariants}
initial="initial"
animate="animate"
exit="exit"
>
<MessageListSkeleton count={3} />
</motion.div>
)}
{/* Empty state */}
{!isLoading && messages.length === 0 && (
<motion.div
key="empty-state"
variants={fadeInVariants}
initial="initial"
animate="animate"
exit="exit"
>
{showFirstPrompt && currentClone ? (
<FirstConversationPrompt
clone={currentClone}
onSelectSuggestion={handleSelectSuggestion}
/>
) : (
<EmptyState
icon={<MessageSquare className="w-8 h-8" />}
title="Welcome to ZCLAW"
description={connected ? 'Send a message to start the conversation.' : 'Please connect to Gateway first in Settings.'}
/>
)}
</motion.div>
)}
{/* Virtualized list for large message counts, smooth scroll for small counts */}
{useVirtualization && messages.length > 0 ? (
<VirtualizedMessageList
messages={messages}
listRef={listRef}
getHeight={getHeight}
onHeightChange={setHeight}
messageRefs={messageRefs}
setInput={setInput}
/>
) : (
messages.map((message) => (
<motion.div
key={message.id}
ref={(el) => { if (el) messageRefs.current.set(message.id, el); }}
variants={listItemVariants}
initial="hidden"
animate="visible"
layout
transition={defaultTransition}
>
<MessageBubble message={message} setInput={setInput} />
</motion.div>
))
)}
</AnimatePresence>
</Conversation>
{/* Input */}
<div className="p-4 bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto">
{/* 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}
onChange={(e) => { setInput(e.target.value); adjustTextarea(); }}
onKeyDown={handleKeyDown}
placeholder={
isStreaming
? 'Agent 正在回复...'
: '今天我能为你做些什么?'
}
disabled={isStreaming}
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>
{/* 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>
</div>
</div>
}
rightPanel={artifactRightPanel}
rightPanelTitle="产物"
rightPanelOpen={artifactPanelOpen}
onRightPanelToggle={setArtifactPanelOpen}
/>
</div>
);
}
function MessageBubble({ message, setInput }: { message: Message; setInput: (text: string) => void }) {
if (message.role === 'tool') {
return null;
}
const isUser = message.role === 'user';
const isThinking = message.streaming && !message.content;
// Download message as Markdown file
const handleDownloadMessage = () => {
if (!message.content) return;
const timestamp = new Date().toISOString().slice(0, 10);
const filename = `message-${timestamp}.md`;
const blob = new Blob([message.content], { type: 'text/markdown;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);
};
return (
<div className={`flex gap-4 ${isUser ? 'justify-end' : ''}`}>
<div
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${isUser ? 'bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-200 order-last' : 'agent-avatar text-white'}`}
>
{isUser ? '用' : 'Z'}
</div>
<div className={isUser ? 'max-w-2xl' : 'flex-1 max-w-3xl'}>
{isThinking ? (
// Thinking indicator
<div className="flex items-center gap-2 px-4 py-3 text-gray-500 dark:text-gray-400">
<LoadingDots />
<span className="text-sm">Thinking...</span>
</div>
) : (
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'} relative group`}>
{/* 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
: <StreamingText
content={message.content}
isStreaming={!!message.streaming}
className="text-gray-700 dark:text-gray-200"
/>
)
: '...'}
</div>
{message.error && (
<div className="flex items-center gap-2 mt-2">
<p className="text-xs text-red-500">{message.error}</p>
<button
onClick={() => {
const text = typeof message.content === 'string' ? message.content : '';
if (text) {
setInput(text);
}
}}
className="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors"
>
</button>
</div>
)}
{/* Download button for AI messages - show on hover */}
{!isUser && message.content && !message.streaming && (
<button
onClick={handleDownloadMessage}
className="absolute top-2 right-2 p-1.5 bg-gray-200/80 dark:bg-gray-700/80 hover:bg-gray-300 dark:hover:bg-gray-600 rounded text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors opacity-0 group-hover:opacity-100"
title="下载为 Markdown"
>
<Download className="w-3.5 h-3.5" />
</button>
)}
</div>
)}
</div>
</div>
);
}
// === Virtualized Message Components ===
interface VirtualizedMessageRowProps {
message: Message;
onHeightChange: (height: number) => void;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
setInput: (text: string) => void;
}
/**
* Single row in the virtualized list.
* Measures actual height after render and reports back.
*/
function VirtualizedMessageRow({
message,
onHeightChange,
messageRefs,
setInput,
style,
ariaAttributes,
}: VirtualizedMessageRowProps & {
style: CSSProperties;
ariaAttributes: {
'aria-posinset': number;
'aria-setsize': number;
role: 'listitem';
};
}) {
const rowRef = useRef<HTMLDivElement>(null);
// Measure height after mount
useEffect(() => {
if (rowRef.current) {
const height = rowRef.current.getBoundingClientRect().height;
if (height > 0) {
onHeightChange(height);
}
}
}, [message.content, message.streaming, onHeightChange]);
return (
<div
ref={(el) => {
if (el) {
(rowRef as MutableRefObject<HTMLDivElement | null>).current = el;
messageRefs.current.set(message.id, el);
}
}}
style={style}
className="py-3"
{...ariaAttributes}
>
<MessageBubble message={message} setInput={setInput} />
</div>
);
}
interface VirtualizedMessageListProps {
messages: Message[];
listRef: RefObject<ListImperativeAPI | null>;
getHeight: (id: string, role: string) => number;
onHeightChange: (id: string, height: number) => void;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
setInput: (text: string) => void;
}
/**
* Virtualized message list for efficient rendering of large message counts.
* Uses react-window's List with dynamic height measurement.
*/
function VirtualizedMessageList({
messages,
listRef,
getHeight,
onHeightChange,
messageRefs,
setInput,
}: VirtualizedMessageListProps) {
// Row component for react-window v2
const RowComponent = (props: {
ariaAttributes: {
'aria-posinset': number;
'aria-setsize': number;
role: 'listitem';
};
index: number;
style: CSSProperties;
}) => (
<VirtualizedMessageRow
message={messages[props.index]}
onHeightChange={(h) => onHeightChange(messages[props.index].id, h)}
messageRefs={messageRefs}
setInput={setInput}
style={props.style}
ariaAttributes={props.ariaAttributes}
/>
);
return (
<List
listRef={listRef}
rowComponent={RowComponent}
rowProps={{}}
rowHeight={(index: number) => getHeight(messages[index].id, messages[index].role)}
rowCount={messages.length}
defaultHeight={500}
overscanCount={5}
className="focus:outline-none"
/>
);
}