From c956ac0c0ffd4ca701a7959205c8b704495c314f Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 11:32:32 -0600 Subject: [PATCH] [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 --- .../assistant/components/ChatHeader.tsx | 275 ++++++++++++ .../assistant/components/MarkdownRenderer.tsx | 402 ++++++++++++++++++ .../assistant/components/MessageList.tsx | 219 ++++++++++ .../assistant/components/MessageSearch.tsx | 363 ++++++++++++++++ src/modules/assistant/components/index.ts | 11 + 5 files changed, 1270 insertions(+) create mode 100644 src/modules/assistant/components/ChatHeader.tsx create mode 100644 src/modules/assistant/components/MarkdownRenderer.tsx create mode 100644 src/modules/assistant/components/MessageList.tsx create mode 100644 src/modules/assistant/components/MessageSearch.tsx diff --git a/src/modules/assistant/components/ChatHeader.tsx b/src/modules/assistant/components/ChatHeader.tsx new file mode 100644 index 0000000..93a9cab --- /dev/null +++ b/src/modules/assistant/components/ChatHeader.tsx @@ -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 = ({ + 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 ( +
+ {/* Left Section */} +
+ {showBackButton && onBack && ( + + )} + + {/* AI Assistant Icon */} +
+
+ +
+ {/* Connection Status Dot */} +
+
+ + {/* Title & Status */} +
+ {isEditing ? ( +
+ 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..." + /> + + +
+ ) : ( + <> +
+

+ {session?.title || 'New Conversation'} +

+ {session?.isPinned && ( + + )} +
+
+ {isStreaming ? ( + + + Generating... + + ) : ( + <> + {isConnected ? ( + + + Connected + + ) : ( + + + Disconnected + + )} + {session && ( + <> + + {session.messageCount} messages + + )} + + )} +
+ + )} +
+
+ + {/* Right Section - Actions */} +
+ {/* Settings Button */} + {onSettings && ( + + )} + + {/* More Menu */} + {menuItems.length > 0 && ( +
+ + + {showMenu && ( + <> + {/* Backdrop */} +
setShowMenu(false)} + /> + {/* Menu */} +
+ {menuItems.map((item, index) => { + if (!item) return null; + const Icon = item.icon; + return ( + + ); + })} +
+ + )} +
+ )} +
+
+ ); +}; + +export default ChatHeader; diff --git a/src/modules/assistant/components/MarkdownRenderer.tsx b/src/modules/assistant/components/MarkdownRenderer.tsx new file mode 100644 index 0000000..89102b8 --- /dev/null +++ b/src/modules/assistant/components/MarkdownRenderer.tsx @@ -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( + + {listItems.map((item, i) => ( +
  • + {parseInlineMarkdown(item)} +
  • + ))} +
    + ); + 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( + + ); + 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( +

    + {parseInlineMarkdown(line.slice(4))} +

    + ); + continue; + } + if (line.startsWith('## ')) { + flushList(); + elements.push( +

    + {parseInlineMarkdown(line.slice(3))} +

    + ); + continue; + } + if (line.startsWith('# ')) { + flushList(); + elements.push( +

    + {parseInlineMarkdown(line.slice(2))} +

    + ); + continue; + } + + // Blockquotes + if (line.startsWith('> ')) { + flushList(); + elements.push( +
    + {parseInlineMarkdown(line.slice(2))} +
    + ); + continue; + } + + // Horizontal rule + if (line.match(/^(-{3,}|\*{3,}|_{3,})$/)) { + flushList(); + elements.push(
    ); + 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( +

    + {parseInlineMarkdown(line)} +

    + ); + } + + // Flush any remaining list + flushList(); + + // Handle unclosed code block + if (inCodeBlock && codeBlockContent.length > 0) { + elements.push( + + ); + } + + 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( + + {match[3]} + + ); + 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( + + {match[3]} + + ); + remaining = match[4]; + continue; + } + + // Inline code `code` + match = remaining.match(/^(.*?)`(.+?)`(.*)$/s); + if (match) { + if (match[1]) parts.push(match[1]); + parts.push( + + {match[2]} + + ); + remaining = match[3]; + continue; + } + + // Links [text](url) + match = remaining.match(/^(.*?)\[(.+?)\]\((.+?)\)(.*)$/s); + if (match) { + if (match[1]) parts.push(match[1]); + parts.push( + + {match[2]} + + + ); + 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 ( +
    +
    + {language || 'code'} + +
    +
    +        {code}
    +      
    +
    + ); +}; + +// 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 ( +
    + +
    {children}
    +
    + ); +}; + +// Trading signal component +const SignalCard: React.FC<{ + signal: ParsedSignal; + onClick?: () => void; +}> = ({ signal, onClick }) => { + const isBuy = signal.action === 'BUY'; + + return ( + + ); +}; + +const MarkdownRenderer: React.FC = ({ + content, + className = '', + onLinkClick, + onSignalClick, + enableCodeCopy = true, + enableSignalParsing = true, + compact = false, +}) => { + const renderedContent = useMemo(() => { + if (!content) return null; + return parseMarkdown(content); + }, [content]); + + return ( +
    + {renderedContent} +
    + ); +}; + +export default MarkdownRenderer; +export { CodeBlock, AlertBox, SignalCard, parseMarkdown, parseInlineMarkdown }; diff --git a/src/modules/assistant/components/MessageList.tsx b/src/modules/assistant/components/MessageList.tsx new file mode 100644 index 0000000..f6354ae --- /dev/null +++ b/src/modules/assistant/components/MessageList.tsx @@ -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; +} + +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 = ({ + messages, + isLoading = false, + onRetry, + onFeedback, + onSignalExecute, + highlightedMessageId, + searchQuery, + compact = false, +}) => { + const containerRef = useRef(null); + const bottomRef = useRef(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() ? ( + + {part} + + ) : ( + part + ) + ); + }; + + if (messages.length === 0 && !isLoading) { + return ( +
    +
    +
    + + + +
    +

    Start a Conversation

    +

    + Ask me about trading strategies, market analysis, or get help with your portfolio. +

    +
    +
    + ); + } + + return ( +
    + {/* Search Results Summary */} + {searchQuery && ( +
    + + Found {filteredMessages.length} message{filteredMessages.length !== 1 ? 's' : ''} matching "{searchQuery}" + +
    + )} + + {/* Messages Container */} +
    +
    + {filteredMessages.map((message, index) => ( +
    + onRetry(message.id) : undefined} + onFeedback={ + onFeedback && message.role === 'assistant' + ? (positive) => onFeedback(message.id, positive) + : undefined + } + onSignalExecute={onSignalExecute} + compact={compact} + /> +
    + ))} + + {/* Loading Indicator */} + {isLoading && ( +
    +
    + +
    +
    +
    +
    +
    +
    + )} + + {/* Bottom anchor for scrolling */} +
    +
    +
    + + {/* Scroll to Bottom Button */} + {showScrollButton && ( + + )} +
    + ); +}; + +export default MessageList; diff --git a/src/modules/assistant/components/MessageSearch.tsx b/src/modules/assistant/components/MessageSearch.tsx new file mode 100644 index 0000000..6131357 --- /dev/null +++ b/src/modules/assistant/components/MessageSearch.tsx @@ -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 = ({ + messages, + onResultSelect, + onSearchChange, + onClose, + isOpen = true, + compact = false, +}) => { + const [query, setQuery] = useState(''); + const [currentIndex, setCurrentIndex] = useState(0); + const [roleFilter, setRoleFilter] = useState('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 ( +
    + {/* Search Bar */} +
    +
    + + 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 && ( + + )} +
    + + {/* Results Count & Navigation */} + {query && ( +
    + + {results.length > 0 ? `${currentIndex + 1}/${results.length}` : '0 results'} + + + +
    + )} + + {/* Filter Toggle */} + + + {/* Close Button */} + {onClose && ( + + )} +
    + + {/* Filters Panel */} + {showFilters && ( +
    + {/* Role Filter */} +
    + From: +
    + + + +
    +
    + + {/* Date Filter */} +
    + + +
    + + {/* Results Stats */} +
    + + Searching {filteredMessages.length} messages +
    +
    + )} + + {/* Quick Results Preview (when query exists) */} + {query && results.length > 0 && !compact && ( +
    + {results.slice(0, 5).map((result, index) => ( + + ))} + {results.length > 5 && ( +

    + +{results.length - 5} more results +

    + )} +
    + )} +
    + ); +}; + +export default MessageSearch; diff --git a/src/modules/assistant/components/index.ts b/src/modules/assistant/components/index.ts index da07ca3..40204b5 100644 --- a/src/modules/assistant/components/index.ts +++ b/src/modules/assistant/components/index.ts @@ -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';