/** * Chat Page * Native React implementation communicating with OpenClaw Gateway * via gateway:rpc IPC. Session selector, thinking toggle, and refresh * are in the toolbar; messages render with markdown + streaming. */ import { useEffect, useRef, useState } from 'react'; import { AlertCircle, Bot, MessageSquare, Sparkles } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { useChatStore, type RawMessage } from '@/stores/chat'; import { useGatewayStore } from '@/stores/gateway'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { ChatMessage } from './ChatMessage'; import { ChatInput } from './ChatInput'; import { ChatToolbar } from './ChatToolbar'; import { extractImages, extractText, extractThinking, extractToolUse } from './message-utils'; import { useTranslation } from 'react-i18next'; export function Chat() { const { t } = useTranslation('chat'); const gatewayStatus = useGatewayStore((s) => s.status); const isGatewayRunning = gatewayStatus.state === 'running'; const messages = useChatStore((s) => s.messages); const loading = useChatStore((s) => s.loading); const sending = useChatStore((s) => s.sending); const error = useChatStore((s) => s.error); const showThinking = useChatStore((s) => s.showThinking); const streamingMessage = useChatStore((s) => s.streamingMessage); const streamingTools = useChatStore((s) => s.streamingTools); const loadHistory = useChatStore((s) => s.loadHistory); const loadSessions = useChatStore((s) => s.loadSessions); const sendMessage = useChatStore((s) => s.sendMessage); const abortRun = useChatStore((s) => s.abortRun); const clearError = useChatStore((s) => s.clearError); const messagesEndRef = useRef(null); const [streamingTimestamp, setStreamingTimestamp] = useState(0); // Load data when gateway is running useEffect(() => { if (!isGatewayRunning) return; let cancelled = false; (async () => { await loadSessions(); if (cancelled) return; await loadHistory(); })(); return () => { cancelled = true; }; }, [isGatewayRunning, loadHistory, loadSessions]); // Auto-scroll on new messages or streaming useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, streamingMessage, sending]); // Update timestamp when sending starts useEffect(() => { if (sending && streamingTimestamp === 0) { // eslint-disable-next-line react-hooks/set-state-in-effect setStreamingTimestamp(Date.now() / 1000); } else if (!sending && streamingTimestamp !== 0) { setStreamingTimestamp(0); } }, [sending, streamingTimestamp]); // Gateway not running if (!isGatewayRunning) { return (

{t('gatewayNotRunning')}

{t('gatewayRequired')}

); } // Extract streaming text for display const streamMsg = streamingMessage && typeof streamingMessage === 'object' ? streamingMessage as unknown as { role?: string; content?: unknown; timestamp?: number } : null; const streamText = streamMsg ? extractText(streamMsg) : (typeof streamingMessage === 'string' ? streamingMessage : ''); const hasStreamText = streamText.trim().length > 0; const streamThinking = streamMsg ? extractThinking(streamMsg) : null; const hasStreamThinking = showThinking && !!streamThinking && streamThinking.trim().length > 0; const streamTools = streamMsg ? extractToolUse(streamMsg) : []; const hasStreamTools = showThinking && streamTools.length > 0; const streamImages = streamMsg ? extractImages(streamMsg) : []; const hasStreamImages = streamImages.length > 0; const hasStreamToolStatus = showThinking && streamingTools.length > 0; const shouldRenderStreaming = sending && (hasStreamText || hasStreamThinking || hasStreamTools || hasStreamImages || hasStreamToolStatus); return (
{/* Toolbar */}
{/* Messages Area */}
{loading ? (
) : messages.length === 0 && !sending ? ( ) : ( <> {messages.map((msg, idx) => ( ))} {/* Streaming message */} {shouldRenderStreaming && ( ), role: (typeof streamMsg.role === 'string' ? streamMsg.role : 'assistant') as RawMessage['role'], content: streamMsg.content ?? streamText, timestamp: streamMsg.timestamp ?? streamingTimestamp, } : { role: 'assistant', content: streamText, timestamp: streamingTimestamp, }) as RawMessage} showThinking={showThinking} isStreaming streamingTools={streamingTools} /> )} {/* Typing indicator when sending but no stream yet */} {sending && !hasStreamText && !hasStreamThinking && !hasStreamTools && !hasStreamImages && !hasStreamToolStatus && ( )} )} {/* Scroll anchor */}
{/* Error bar */} {error && (

{error}

)} {/* Input Area */}
); } // ── Welcome Screen ────────────────────────────────────────────── function WelcomeScreen() { const { t } = useTranslation('chat'); return (

{t('welcome.title')}

{t('welcome.subtitle')}

{[ { icon: MessageSquare, title: t('welcome.askQuestions'), desc: t('welcome.askQuestionsDesc') }, { icon: Sparkles, title: t('welcome.creativeTasks'), desc: t('welcome.creativeTasksDesc') }, ].map((item, i) => (

{item.title}

{item.desc}

))}
); } // ── Typing Indicator ──────────────────────────────────────────── function TypingIndicator() { return (
); } export default Chat;