[OQI-007] feat: Add assistant UI components for chat enhancement

- MessageList: Virtualized message list with auto-scroll
- ChatHeader: Header with title editing, actions menu, status
- MessageSearch: Search within conversation with filters
- MarkdownRenderer: Custom markdown rendering with code copy

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 11:32:32 -06:00
parent 4d2c00ac30
commit c956ac0c0f
5 changed files with 1270 additions and 0 deletions

View File

@ -0,0 +1,275 @@
/**
* ChatHeader Component
* Header for chat conversations with title, actions, and status
*/
import React, { useState } from 'react';
import {
MoreVertical,
Edit2,
Trash2,
Download,
Share2,
Pin,
PinOff,
Copy,
Settings,
Wifi,
WifiOff,
Check,
X,
ChevronLeft,
Sparkles,
} from 'lucide-react';
interface ChatSession {
id: string;
title: string;
isPinned?: boolean;
createdAt: string;
messageCount: number;
}
interface ChatHeaderProps {
session: ChatSession | null;
isConnected?: boolean;
isStreaming?: boolean;
onTitleChange?: (newTitle: string) => void;
onDelete?: () => void;
onExport?: () => void;
onShare?: () => void;
onPin?: () => void;
onCopy?: () => void;
onSettings?: () => void;
onBack?: () => void;
showBackButton?: boolean;
compact?: boolean;
}
const ChatHeader: React.FC<ChatHeaderProps> = ({
session,
isConnected = true,
isStreaming = false,
onTitleChange,
onDelete,
onExport,
onShare,
onPin,
onCopy,
onSettings,
onBack,
showBackButton = false,
compact = false,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editedTitle, setEditedTitle] = useState(session?.title || '');
const [showMenu, setShowMenu] = useState(false);
const handleStartEdit = () => {
if (!onTitleChange) return;
setEditedTitle(session?.title || '');
setIsEditing(true);
setShowMenu(false);
};
const handleSaveTitle = () => {
if (editedTitle.trim() && editedTitle !== session?.title) {
onTitleChange?.(editedTitle.trim());
}
setIsEditing(false);
};
const handleCancelEdit = () => {
setEditedTitle(session?.title || '');
setIsEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSaveTitle();
} else if (e.key === 'Escape') {
handleCancelEdit();
}
};
const menuItems = [
onTitleChange && { icon: Edit2, label: 'Rename', action: handleStartEdit },
onPin && {
icon: session?.isPinned ? PinOff : Pin,
label: session?.isPinned ? 'Unpin' : 'Pin',
action: onPin,
},
onCopy && { icon: Copy, label: 'Copy link', action: onCopy },
onExport && { icon: Download, label: 'Export', action: onExport },
onShare && { icon: Share2, label: 'Share', action: onShare },
onDelete && {
icon: Trash2,
label: 'Delete',
action: onDelete,
danger: true,
},
].filter(Boolean);
return (
<div
className={`flex items-center justify-between border-b border-gray-700 bg-gray-800/50 ${
compact ? 'px-3 py-2' : 'px-4 py-3'
}`}
>
{/* Left Section */}
<div className="flex items-center gap-3 flex-1 min-w-0">
{showBackButton && onBack && (
<button
onClick={onBack}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
)}
{/* AI Assistant Icon */}
<div className="relative flex-shrink-0">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-white" />
</div>
{/* Connection Status Dot */}
<div
className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-gray-800 ${
isConnected ? 'bg-green-400' : 'bg-red-400'
}`}
/>
</div>
{/* Title & Status */}
<div className="flex-1 min-w-0">
{isEditing ? (
<div className="flex items-center gap-2">
<input
type="text"
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
className="flex-1 px-2 py-1 bg-gray-900 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-blue-500"
placeholder="Conversation title..."
/>
<button
onClick={handleSaveTitle}
className="p-1 text-green-400 hover:bg-gray-700 rounded"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={handleCancelEdit}
className="p-1 text-gray-400 hover:bg-gray-700 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<>
<div className="flex items-center gap-2">
<h2 className="font-medium text-white truncate">
{session?.title || 'New Conversation'}
</h2>
{session?.isPinned && (
<Pin className="w-3 h-3 text-blue-400 flex-shrink-0" />
)}
</div>
<div className="flex items-center gap-2 text-xs text-gray-500">
{isStreaming ? (
<span className="flex items-center gap-1 text-blue-400">
<span className="w-1.5 h-1.5 bg-blue-400 rounded-full animate-pulse" />
Generating...
</span>
) : (
<>
{isConnected ? (
<span className="flex items-center gap-1">
<Wifi className="w-3 h-3 text-green-400" />
Connected
</span>
) : (
<span className="flex items-center gap-1 text-red-400">
<WifiOff className="w-3 h-3" />
Disconnected
</span>
)}
{session && (
<>
<span></span>
<span>{session.messageCount} messages</span>
</>
)}
</>
)}
</div>
</>
)}
</div>
</div>
{/* Right Section - Actions */}
<div className="flex items-center gap-1">
{/* Settings Button */}
{onSettings && (
<button
onClick={onSettings}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
title="Settings"
>
<Settings className="w-4 h-4" />
</button>
)}
{/* More Menu */}
{menuItems.length > 0 && (
<div className="relative">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
>
<MoreVertical className="w-4 h-4" />
</button>
{showMenu && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-10"
onClick={() => setShowMenu(false)}
/>
{/* Menu */}
<div className="absolute right-0 top-full mt-1 w-48 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-20 py-1">
{menuItems.map((item, index) => {
if (!item) return null;
const Icon = item.icon;
return (
<button
key={index}
onClick={() => {
item.action();
setShowMenu(false);
}}
className={`w-full flex items-center gap-3 px-3 py-2 text-sm transition-colors ${
item.danger
? 'text-red-400 hover:bg-red-500/10'
: 'text-gray-300 hover:bg-gray-700'
}`}
>
<Icon className="w-4 h-4" />
{item.label}
</button>
);
})}
</div>
</>
)}
</div>
)}
</div>
</div>
);
};
export default ChatHeader;

View File

@ -0,0 +1,402 @@
/**
* MarkdownRenderer Component
* Reusable markdown rendering with syntax highlighting and custom components
*/
import React, { useMemo, useState } from 'react';
import {
Copy,
Check,
ExternalLink,
TrendingUp,
TrendingDown,
AlertTriangle,
Info,
CheckCircle,
} from 'lucide-react';
interface MarkdownRendererProps {
content: string;
className?: string;
onLinkClick?: (url: string) => void;
onSignalClick?: (signal: ParsedSignal) => void;
enableCodeCopy?: boolean;
enableSignalParsing?: boolean;
compact?: boolean;
}
interface ParsedSignal {
symbol: string;
action: 'BUY' | 'SELL';
entry?: number;
stopLoss?: number;
takeProfit?: number;
confidence?: number;
}
// Simple markdown parser without external dependencies
const parseMarkdown = (text: string): React.ReactNode[] => {
const elements: React.ReactNode[] = [];
const lines = text.split('\n');
let inCodeBlock = false;
let codeBlockContent: string[] = [];
let codeBlockLanguage = '';
let listItems: string[] = [];
let listType: 'ul' | 'ol' | null = null;
const flushList = () => {
if (listItems.length > 0 && listType) {
const ListTag = listType === 'ol' ? 'ol' : 'ul';
elements.push(
<ListTag
key={`list-${elements.length}`}
className={`my-2 ${listType === 'ol' ? 'list-decimal' : 'list-disc'} list-inside space-y-1`}
>
{listItems.map((item, i) => (
<li key={i} className="text-gray-300">
{parseInlineMarkdown(item)}
</li>
))}
</ListTag>
);
listItems = [];
listType = null;
}
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Code block handling
if (line.startsWith('```')) {
if (!inCodeBlock) {
flushList();
inCodeBlock = true;
codeBlockLanguage = line.slice(3).trim();
codeBlockContent = [];
} else {
elements.push(
<CodeBlock
key={`code-${elements.length}`}
code={codeBlockContent.join('\n')}
language={codeBlockLanguage}
/>
);
inCodeBlock = false;
codeBlockContent = [];
codeBlockLanguage = '';
}
continue;
}
if (inCodeBlock) {
codeBlockContent.push(line);
continue;
}
// Empty line - flush list and add break
if (!line.trim()) {
flushList();
continue;
}
// Headers
if (line.startsWith('### ')) {
flushList();
elements.push(
<h3 key={`h3-${i}`} className="text-lg font-semibold text-white mt-4 mb-2">
{parseInlineMarkdown(line.slice(4))}
</h3>
);
continue;
}
if (line.startsWith('## ')) {
flushList();
elements.push(
<h2 key={`h2-${i}`} className="text-xl font-semibold text-white mt-4 mb-2">
{parseInlineMarkdown(line.slice(3))}
</h2>
);
continue;
}
if (line.startsWith('# ')) {
flushList();
elements.push(
<h1 key={`h1-${i}`} className="text-2xl font-bold text-white mt-4 mb-2">
{parseInlineMarkdown(line.slice(2))}
</h1>
);
continue;
}
// Blockquotes
if (line.startsWith('> ')) {
flushList();
elements.push(
<blockquote
key={`bq-${i}`}
className="border-l-4 border-blue-500 pl-4 py-1 my-2 text-gray-400 italic"
>
{parseInlineMarkdown(line.slice(2))}
</blockquote>
);
continue;
}
// Horizontal rule
if (line.match(/^(-{3,}|\*{3,}|_{3,})$/)) {
flushList();
elements.push(<hr key={`hr-${i}`} className="my-4 border-gray-700" />);
continue;
}
// Unordered list
if (line.match(/^[\s]*[-*+]\s/)) {
if (listType !== 'ul') {
flushList();
listType = 'ul';
}
listItems.push(line.replace(/^[\s]*[-*+]\s/, ''));
continue;
}
// Ordered list
if (line.match(/^[\s]*\d+\.\s/)) {
if (listType !== 'ol') {
flushList();
listType = 'ol';
}
listItems.push(line.replace(/^[\s]*\d+\.\s/, ''));
continue;
}
// Regular paragraph
flushList();
elements.push(
<p key={`p-${i}`} className="text-gray-300 my-1">
{parseInlineMarkdown(line)}
</p>
);
}
// Flush any remaining list
flushList();
// Handle unclosed code block
if (inCodeBlock && codeBlockContent.length > 0) {
elements.push(
<CodeBlock
key={`code-${elements.length}`}
code={codeBlockContent.join('\n')}
language={codeBlockLanguage}
/>
);
}
return elements;
};
// Parse inline markdown (bold, italic, code, links)
const parseInlineMarkdown = (text: string): React.ReactNode => {
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
while (remaining.length > 0) {
// Bold **text** or __text__
let match = remaining.match(/^(.*?)(\*\*|__)(.+?)\2(.*)$/s);
if (match) {
if (match[1]) parts.push(parseInlineMarkdown(match[1]));
parts.push(
<strong key={key++} className="font-semibold text-white">
{match[3]}
</strong>
);
remaining = match[4];
continue;
}
// Italic *text* or _text_
match = remaining.match(/^(.*?)(\*|_)(.+?)\2(.*)$/s);
if (match) {
if (match[1]) parts.push(parseInlineMarkdown(match[1]));
parts.push(
<em key={key++} className="italic">
{match[3]}
</em>
);
remaining = match[4];
continue;
}
// Inline code `code`
match = remaining.match(/^(.*?)`(.+?)`(.*)$/s);
if (match) {
if (match[1]) parts.push(match[1]);
parts.push(
<code
key={key++}
className="px-1.5 py-0.5 bg-gray-700 text-blue-300 rounded text-sm font-mono"
>
{match[2]}
</code>
);
remaining = match[3];
continue;
}
// Links [text](url)
match = remaining.match(/^(.*?)\[(.+?)\]\((.+?)\)(.*)$/s);
if (match) {
if (match[1]) parts.push(match[1]);
parts.push(
<a
key={key++}
href={match[3]}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 underline inline-flex items-center gap-1"
>
{match[2]}
<ExternalLink className="w-3 h-3" />
</a>
);
remaining = match[4];
continue;
}
// No more patterns, add remaining text
parts.push(remaining);
break;
}
return parts.length === 1 ? parts[0] : <>{parts}</>;
};
// Code block component with copy button
const CodeBlock: React.FC<{ code: string; language: string }> = ({ code, language }) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative my-3 rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-gray-900 border-b border-gray-700">
<span className="text-xs text-gray-400 font-mono">{language || 'code'}</span>
<button
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-400 hover:text-white transition-colors"
>
{copied ? (
<>
<Check className="w-3 h-3 text-green-400" />
Copied
</>
) : (
<>
<Copy className="w-3 h-3" />
Copy
</>
)}
</button>
</div>
<pre className="p-4 bg-gray-900/50 overflow-x-auto">
<code className="text-sm font-mono text-gray-300 whitespace-pre">{code}</code>
</pre>
</div>
);
};
// Alert/callout component
const AlertBox: React.FC<{
type: 'info' | 'warning' | 'success' | 'error';
children: React.ReactNode;
}> = ({ type, children }) => {
const styles = {
info: { bg: 'bg-blue-500/10', border: 'border-blue-500/30', icon: Info, color: 'text-blue-400' },
warning: { bg: 'bg-yellow-500/10', border: 'border-yellow-500/30', icon: AlertTriangle, color: 'text-yellow-400' },
success: { bg: 'bg-green-500/10', border: 'border-green-500/30', icon: CheckCircle, color: 'text-green-400' },
error: { bg: 'bg-red-500/10', border: 'border-red-500/30', icon: AlertTriangle, color: 'text-red-400' },
};
const style = styles[type];
const Icon = style.icon;
return (
<div className={`flex items-start gap-3 p-3 my-2 rounded-lg ${style.bg} border ${style.border}`}>
<Icon className={`w-5 h-5 ${style.color} flex-shrink-0 mt-0.5`} />
<div className="flex-1 text-sm text-gray-300">{children}</div>
</div>
);
};
// Trading signal component
const SignalCard: React.FC<{
signal: ParsedSignal;
onClick?: () => void;
}> = ({ signal, onClick }) => {
const isBuy = signal.action === 'BUY';
return (
<button
onClick={onClick}
className={`w-full p-3 my-2 rounded-lg border transition-colors ${
isBuy
? 'bg-green-500/10 border-green-500/30 hover:bg-green-500/20'
: 'bg-red-500/10 border-red-500/30 hover:bg-red-500/20'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isBuy ? (
<TrendingUp className="w-5 h-5 text-green-400" />
) : (
<TrendingDown className="w-5 h-5 text-red-400" />
)}
<span className="font-bold text-white">{signal.symbol}</span>
<span className={`text-sm font-medium ${isBuy ? 'text-green-400' : 'text-red-400'}`}>
{signal.action}
</span>
</div>
{signal.confidence && (
<span className="text-xs text-gray-400">{signal.confidence}% confidence</span>
)}
</div>
{(signal.entry || signal.stopLoss || signal.takeProfit) && (
<div className="flex gap-4 mt-2 text-xs text-gray-400">
{signal.entry && <span>Entry: {signal.entry}</span>}
{signal.stopLoss && <span className="text-red-400">SL: {signal.stopLoss}</span>}
{signal.takeProfit && <span className="text-green-400">TP: {signal.takeProfit}</span>}
</div>
)}
</button>
);
};
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
className = '',
onLinkClick,
onSignalClick,
enableCodeCopy = true,
enableSignalParsing = true,
compact = false,
}) => {
const renderedContent = useMemo(() => {
if (!content) return null;
return parseMarkdown(content);
}, [content]);
return (
<div className={`markdown-content ${compact ? 'text-sm' : ''} ${className}`}>
{renderedContent}
</div>
);
};
export default MarkdownRenderer;
export { CodeBlock, AlertBox, SignalCard, parseMarkdown, parseInlineMarkdown };

View File

@ -0,0 +1,219 @@
/**
* MessageList Component
* Virtualized message list with auto-scroll and performance optimization
*/
import React, { useRef, useEffect, useCallback, useState } from 'react';
import { ArrowDown, Loader2 } from 'lucide-react';
import ChatMessage from './ChatMessage';
export interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: string;
isStreaming?: boolean;
toolCalls?: ToolCall[];
error?: string;
metadata?: Record<string, unknown>;
}
interface ToolCall {
id: string;
name: string;
status: 'pending' | 'running' | 'success' | 'error';
result?: unknown;
}
interface MessageListProps {
messages: Message[];
isLoading?: boolean;
onRetry?: (messageId: string) => void;
onFeedback?: (messageId: string, positive: boolean) => void;
onSignalExecute?: (signal: unknown) => void;
highlightedMessageId?: string;
searchQuery?: string;
compact?: boolean;
}
const MessageList: React.FC<MessageListProps> = ({
messages,
isLoading = false,
onRetry,
onFeedback,
onSignalExecute,
highlightedMessageId,
searchQuery,
compact = false,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const [autoScroll, setAutoScroll] = useState(true);
// Check if user has scrolled away from bottom
const handleScroll = useCallback(() => {
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
const isNearBottom = distanceFromBottom < 100;
setShowScrollButton(!isNearBottom);
setAutoScroll(isNearBottom);
}, []);
// Scroll to bottom
const scrollToBottom = useCallback((smooth = true) => {
bottomRef.current?.scrollIntoView({
behavior: smooth ? 'smooth' : 'auto',
block: 'end',
});
setShowScrollButton(false);
setAutoScroll(true);
}, []);
// Scroll to highlighted message
const scrollToMessage = useCallback((messageId: string) => {
const element = document.getElementById(`message-${messageId}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, []);
// Auto-scroll when new messages arrive (if user hasn't scrolled up)
useEffect(() => {
if (autoScroll && messages.length > 0) {
scrollToBottom(false);
}
}, [messages.length, autoScroll, scrollToBottom]);
// Scroll to highlighted message when it changes
useEffect(() => {
if (highlightedMessageId) {
scrollToMessage(highlightedMessageId);
}
}, [highlightedMessageId, scrollToMessage]);
// Filter and highlight messages based on search
const filteredMessages = searchQuery
? messages.filter((msg) =>
msg.content.toLowerCase().includes(searchQuery.toLowerCase())
)
: messages;
// Highlight search matches in content
const highlightText = (text: string, query: string): React.ReactNode => {
if (!query) return text;
const parts = text.split(new RegExp(`(${query})`, 'gi'));
return parts.map((part, index) =>
part.toLowerCase() === query.toLowerCase() ? (
<mark key={index} className="bg-yellow-500/30 text-yellow-200 px-0.5 rounded">
{part}
</mark>
) : (
part
)
);
};
if (messages.length === 0 && !isLoading) {
return (
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-800 flex items-center justify-center">
<svg className="w-8 h-8 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</div>
<h3 className="text-lg font-medium text-white mb-2">Start a Conversation</h3>
<p className="text-gray-400 text-sm max-w-sm">
Ask me about trading strategies, market analysis, or get help with your portfolio.
</p>
</div>
</div>
);
}
return (
<div className="relative flex-1 flex flex-col min-h-0">
{/* Search Results Summary */}
{searchQuery && (
<div className="px-4 py-2 bg-yellow-500/10 border-b border-yellow-500/30">
<span className="text-sm text-yellow-400">
Found {filteredMessages.length} message{filteredMessages.length !== 1 ? 's' : ''} matching "{searchQuery}"
</span>
</div>
)}
{/* Messages Container */}
<div
ref={containerRef}
onScroll={handleScroll}
className={`flex-1 overflow-y-auto ${compact ? 'px-3 py-2' : 'px-4 py-4'}`}
>
<div className="max-w-4xl mx-auto space-y-4">
{filteredMessages.map((message, index) => (
<div
key={message.id}
id={`message-${message.id}`}
className={`transition-all duration-200 ${
highlightedMessageId === message.id
? 'ring-2 ring-blue-500 ring-offset-2 ring-offset-gray-900 rounded-lg'
: ''
}`}
>
<ChatMessage
message={{
...message,
content: searchQuery
? String(highlightText(message.content, searchQuery))
: message.content,
}}
isLast={index === filteredMessages.length - 1}
onRetry={onRetry ? () => onRetry(message.id) : undefined}
onFeedback={
onFeedback && message.role === 'assistant'
? (positive) => onFeedback(message.id, positive)
: undefined
}
onSignalExecute={onSignalExecute}
compact={compact}
/>
</div>
))}
{/* Loading Indicator */}
{isLoading && (
<div className="flex items-center gap-3 p-4">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
<Loader2 className="w-4 h-4 text-white animate-spin" />
</div>
<div className="flex-1">
<div className="h-4 bg-gray-700 rounded w-3/4 animate-pulse" />
<div className="h-3 bg-gray-700 rounded w-1/2 animate-pulse mt-2" />
</div>
</div>
)}
{/* Bottom anchor for scrolling */}
<div ref={bottomRef} />
</div>
</div>
{/* Scroll to Bottom Button */}
{showScrollButton && (
<button
onClick={() => scrollToBottom(true)}
className="absolute bottom-4 right-4 p-2 bg-blue-600 hover:bg-blue-500 text-white rounded-full shadow-lg transition-all transform hover:scale-105"
aria-label="Scroll to bottom"
>
<ArrowDown className="w-5 h-5" />
</button>
)}
</div>
);
};
export default MessageList;

View File

@ -0,0 +1,363 @@
/**
* MessageSearch Component
* Search within conversation messages with filters
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import {
Search,
X,
ChevronUp,
ChevronDown,
Filter,
User,
Bot,
Calendar,
Hash,
} from 'lucide-react';
interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: string;
}
interface SearchResult {
messageId: string;
matchIndex: number;
snippet: string;
role: 'user' | 'assistant' | 'system';
timestamp: string;
}
interface MessageSearchProps {
messages: Message[];
onResultSelect?: (messageId: string) => void;
onSearchChange?: (query: string) => void;
onClose?: () => void;
isOpen?: boolean;
compact?: boolean;
}
type RoleFilter = 'all' | 'user' | 'assistant';
const MessageSearch: React.FC<MessageSearchProps> = ({
messages,
onResultSelect,
onSearchChange,
onClose,
isOpen = true,
compact = false,
}) => {
const [query, setQuery] = useState('');
const [currentIndex, setCurrentIndex] = useState(0);
const [roleFilter, setRoleFilter] = useState<RoleFilter>('all');
const [showFilters, setShowFilters] = useState(false);
const [dateFilter, setDateFilter] = useState<'all' | 'today' | 'week' | 'month'>('all');
// Filter messages by role and date
const filteredMessages = useMemo(() => {
let result = messages;
// Role filter
if (roleFilter !== 'all') {
result = result.filter((msg) => msg.role === roleFilter);
}
// Date filter
if (dateFilter !== 'all') {
const now = new Date();
const cutoff = new Date();
switch (dateFilter) {
case 'today':
cutoff.setHours(0, 0, 0, 0);
break;
case 'week':
cutoff.setDate(now.getDate() - 7);
break;
case 'month':
cutoff.setMonth(now.getMonth() - 1);
break;
}
result = result.filter((msg) => new Date(msg.timestamp) >= cutoff);
}
return result;
}, [messages, roleFilter, dateFilter]);
// Search results
const results: SearchResult[] = useMemo(() => {
if (!query.trim()) return [];
const searchResults: SearchResult[] = [];
const searchTerm = query.toLowerCase();
filteredMessages.forEach((message) => {
const content = message.content.toLowerCase();
let index = content.indexOf(searchTerm);
let matchIndex = 0;
while (index !== -1) {
// Create snippet with context
const start = Math.max(0, index - 30);
const end = Math.min(message.content.length, index + query.length + 30);
let snippet = message.content.substring(start, end);
if (start > 0) snippet = '...' + snippet;
if (end < message.content.length) snippet = snippet + '...';
searchResults.push({
messageId: message.id,
matchIndex,
snippet,
role: message.role,
timestamp: message.timestamp,
});
matchIndex++;
index = content.indexOf(searchTerm, index + 1);
}
});
return searchResults;
}, [query, filteredMessages]);
// Update parent when query changes
useEffect(() => {
onSearchChange?.(query);
}, [query, onSearchChange]);
// Navigate to current result
useEffect(() => {
if (results.length > 0 && currentIndex < results.length) {
onResultSelect?.(results[currentIndex].messageId);
}
}, [currentIndex, results, onResultSelect]);
// Reset current index when results change
useEffect(() => {
setCurrentIndex(0);
}, [results.length]);
const handlePrevious = useCallback(() => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : results.length - 1));
}, [results.length]);
const handleNext = useCallback(() => {
setCurrentIndex((prev) => (prev < results.length - 1 ? prev + 1 : 0));
}, [results.length]);
const handleClear = useCallback(() => {
setQuery('');
setCurrentIndex(0);
onSearchChange?.('');
}, [onSearchChange]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
if (e.shiftKey) {
handlePrevious();
} else {
handleNext();
}
} else if (e.key === 'Escape') {
onClose?.();
}
},
[handleNext, handlePrevious, onClose]
);
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
};
if (!isOpen) return null;
return (
<div className={`bg-gray-800 border-b border-gray-700 ${compact ? 'px-2 py-1.5' : 'px-4 py-2'}`}>
{/* Search Bar */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search messages..."
autoFocus
className="w-full pl-9 pr-8 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm placeholder-gray-500 focus:outline-none focus:border-blue-500"
/>
{query && (
<button
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-500 hover:text-gray-300"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Results Count & Navigation */}
{query && (
<div className="flex items-center gap-1">
<span className="text-sm text-gray-400 whitespace-nowrap">
{results.length > 0 ? `${currentIndex + 1}/${results.length}` : '0 results'}
</span>
<button
onClick={handlePrevious}
disabled={results.length === 0}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded disabled:opacity-50 disabled:cursor-not-allowed"
title="Previous (Shift+Enter)"
>
<ChevronUp className="w-4 h-4" />
</button>
<button
onClick={handleNext}
disabled={results.length === 0}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded disabled:opacity-50 disabled:cursor-not-allowed"
title="Next (Enter)"
>
<ChevronDown className="w-4 h-4" />
</button>
</div>
)}
{/* Filter Toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className={`p-2 rounded-lg transition-colors ${
showFilters ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-700'
}`}
title="Filters"
>
<Filter className="w-4 h-4" />
</button>
{/* Close Button */}
{onClose && (
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg"
title="Close (Esc)"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Filters Panel */}
{showFilters && (
<div className="flex items-center gap-4 mt-2 pt-2 border-t border-gray-700">
{/* Role Filter */}
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">From:</span>
<div className="flex items-center gap-1">
<button
onClick={() => setRoleFilter('all')}
className={`px-2 py-1 text-xs rounded ${
roleFilter === 'all'
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
All
</button>
<button
onClick={() => setRoleFilter('user')}
className={`flex items-center gap-1 px-2 py-1 text-xs rounded ${
roleFilter === 'user'
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
<User className="w-3 h-3" />
You
</button>
<button
onClick={() => setRoleFilter('assistant')}
className={`flex items-center gap-1 px-2 py-1 text-xs rounded ${
roleFilter === 'assistant'
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
<Bot className="w-3 h-3" />
AI
</button>
</div>
</div>
{/* Date Filter */}
<div className="flex items-center gap-2">
<Calendar className="w-3 h-3 text-gray-500" />
<select
value={dateFilter}
onChange={(e) => setDateFilter(e.target.value as typeof dateFilter)}
className="px-2 py-1 bg-gray-700 border border-gray-600 rounded text-xs text-white focus:outline-none"
>
<option value="all">All time</option>
<option value="today">Today</option>
<option value="week">Past week</option>
<option value="month">Past month</option>
</select>
</div>
{/* Results Stats */}
<div className="flex items-center gap-1 text-xs text-gray-500">
<Hash className="w-3 h-3" />
<span>Searching {filteredMessages.length} messages</span>
</div>
</div>
)}
{/* Quick Results Preview (when query exists) */}
{query && results.length > 0 && !compact && (
<div className="mt-2 max-h-40 overflow-y-auto space-y-1">
{results.slice(0, 5).map((result, index) => (
<button
key={`${result.messageId}-${result.matchIndex}`}
onClick={() => {
setCurrentIndex(index);
onResultSelect?.(result.messageId);
}}
className={`w-full flex items-start gap-2 p-2 rounded text-left transition-colors ${
index === currentIndex
? 'bg-blue-600/20 border border-blue-500/30'
: 'hover:bg-gray-700'
}`}
>
<div
className={`mt-0.5 p-1 rounded ${
result.role === 'user' ? 'bg-green-500/20' : 'bg-blue-500/20'
}`}
>
{result.role === 'user' ? (
<User className="w-3 h-3 text-green-400" />
) : (
<Bot className="w-3 h-3 text-blue-400" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-300 truncate">{result.snippet}</p>
<p className="text-xs text-gray-500">{formatTime(result.timestamp)}</p>
</div>
</button>
))}
{results.length > 5 && (
<p className="text-xs text-gray-500 text-center py-1">
+{results.length - 5} more results
</p>
)}
</div>
)}
</div>
);
};
export default MessageSearch;

View File

@ -36,3 +36,14 @@ export type { AssistantSettings } from './AssistantSettingsPanel';
// Signal Execution
export { default as SignalExecutionPanel } from './SignalExecutionPanel';
export type { ExecutionParams, ExecutionResult } from './SignalExecutionPanel';
// Message List & Layout
export { default as MessageList } from './MessageList';
export { default as ChatHeader } from './ChatHeader';
// Search
export { default as MessageSearch } from './MessageSearch';
// Rendering
export { default as MarkdownRenderer } from './MarkdownRenderer';
export { CodeBlock, AlertBox, SignalCard as MarkdownSignalCard, parseMarkdown } from './MarkdownRenderer';