readysite / website / frontend / components / ChatPanel.jsx
29.6 KB
ChatPanel.jsx
import * as React from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { apiGet, apiPost, apiDelete, apiPatch } from '../hooks/useAPI.js';
import { ConversationList } from './ConversationList.jsx';

// Available AI models by provider
const MODELS_BY_PROVIDER = {
  anthropic: [
    { id: 'claude-sonnet-4-5-20250929', name: 'Sonnet 4.5' },
    { id: 'claude-opus-4-5-20251101', name: 'Opus 4.5' },
  ],
  openai: [
    { id: 'gpt-4o', name: 'GPT-4o' },
    { id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
  ],
};

export function ChatPanel({ open, onToggle, onNavigate }) {
  const [conversations, setConversations] = useState([]);
  const [currentConversation, setCurrentConversation] = useState(null);
  const [messages, setMessages] = useState([]);
  const [canUndo, setCanUndo] = useState(false);
  const [canRedo, setCanRedo] = useState(false);
  const [loading, setLoading] = useState(true);
  const [sending, setSending] = useState(false);
  const [streaming, setStreaming] = useState(false);
  const [streamStatus, setStreamStatus] = useState(''); // 'Thinking...', 'Processing...', 'Rate limited...', etc.
  const [aiConfigured, setAiConfigured] = useState(true);
  const [aiProvider, setAiProvider] = useState('');
  const [inputValue, setInputValue] = useState('');
  const [showConversations, setShowConversations] = useState(false);
  const [model, setModel] = useState('');
  const [attachedFiles, setAttachedFiles] = useState([]);
  const messagesEndRef = useRef(null);
  const inputRef = useRef(null);
  const fileInputRef = useRef(null);
  const eventSourceRef = useRef(null);

  // Load conversations on mount
  useEffect(() => {
    loadConversations();
    checkAiConfig();
  }, []);

  // Auto-scroll to bottom when messages change
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  // Focus input when panel opens
  useEffect(() => {
    if (open && inputRef.current) {
      inputRef.current.focus();
    }
  }, [open]);

  // Cleanup event source on unmount
  useEffect(() => {
    return () => {
      if (eventSourceRef.current) {
        eventSourceRef.current.close();
      }
    };
  }, []);

  const checkAiConfig = async () => {
    try {
      const settings = await apiGet('/api/admin/settings');
      setAiConfigured(settings?.aiProvider && settings?.aiProvider !== 'mock');
      setAiProvider(settings?.aiProvider || '');
    } catch {
      setAiConfigured(false);
    }
  };

  const loadConversations = async () => {
    try {
      const data = await apiGet('/api/admin/conversations');
      setConversations(data?.items || []);
      setLoading(false);
      // Start with empty conversation - will create on first message
    } catch (err) {
      console.error('Failed to load conversations:', err);
      setLoading(false);
    }
  };

  const loadConversation = async (id) => {
    try {
      const data = await apiGet(`/api/admin/conversations/${id}`);
      setCurrentConversation(data);
      setMessages(data?.messages || []);
      setCanUndo(data?.canUndo || false);
      setCanRedo(data?.canRedo || false);
      setModel(data?.model || '');

      // If there's a pending/streaming message, connect to stream
      if (data?.streamingMessageId) {
        connectToStream(id);
      }
    } catch (err) {
      console.error('Failed to load conversation:', err);
    }
  };

  const startNewConversation = () => {
    setCurrentConversation(null);
    setMessages([]);
    setCanUndo(false);
    setCanRedo(false);
    setModel('');
    setInputValue('');
    setAttachedFiles([]);
    if (eventSourceRef.current) {
      eventSourceRef.current.close();
      eventSourceRef.current = null;
    }
    setStreaming(false);
    setStreamStatus('');
  };

  const createConversation = async (content = '') => {
    try {
      const data = await apiPost('/api/admin/conversations', { content });
      setCurrentConversation(data);
      setConversations(prev => [data, ...prev]);
      setModel('');

      if (content) {
        // Reload to get messages
        await loadConversation(data.id);
        connectToStream(data.id);
      } else {
        setMessages([]);
      }
    } catch (err) {
      console.error('Failed to create conversation:', err);
    }
  };

  const sendMessage = async (content) => {
    if (!content.trim() || sending || streaming) return;

    setSending(true);
    try {
      // Upload attached files first
      let fileIds = [];
      if (attachedFiles.length > 0) {
        for (const file of attachedFiles) {
          try {
            const formData = new FormData();
            formData.append('file', file);
            const response = await fetch('/api/files', {
              method: 'POST',
              body: formData,
            });
            const uploaded = await response.json();
            if (uploaded?.id) {
              fileIds.push(uploaded.id);
            }
          } catch (uploadErr) {
            console.error('Failed to upload file:', file.name, uploadErr);
          }
        }
      }

      let convId = currentConversation?.id;

      // Create new conversation if needed
      if (!convId) {
        const newConv = await apiPost('/api/admin/conversations', { content, fileIds });
        setCurrentConversation(newConv);
        setConversations(prev => [newConv, ...prev]);
        convId = newConv.id;

        // Fetch the conversation to get the messages
        const data = await apiGet(`/api/admin/conversations/${convId}`);
        setMessages(data?.messages || []);
      } else {
        // Send message to existing conversation
        const response = await apiPost(`/api/admin/conversations/${convId}/messages`, { content, fileIds });
        setMessages(prev => [...prev, response.userMessage, response.assistantMessage]);
      }

      setInputValue('');
      setAttachedFiles([]); // Clear attached files after sending
      connectToStream(convId);
    } catch (err) {
      console.error('Failed to send message:', err);
    } finally {
      setSending(false);
    }
  };

  const connectToStream = useCallback((convId) => {
    // Close existing connection
    if (eventSourceRef.current) {
      eventSourceRef.current.close();
    }

    setStreaming(true);
    setStreamStatus('Connecting...');
    const eventSource = new EventSource(`/api/admin/conversations/${convId}/stream`);
    eventSourceRef.current = eventSource;

    let streamContent = '';

    // Listen for status updates (thinking, rate limited, etc.)
    eventSource.addEventListener('status', (event) => {
      setStreamStatus(event.data);
    });

    eventSource.addEventListener('content', (event) => {
      setStreamStatus(''); // Clear status once content starts flowing
      streamContent += event.data;
      setMessages(prev => {
        const updated = [...prev];
        const lastMsg = updated[updated.length - 1];
        if (lastMsg && lastMsg.role === 'assistant') {
          lastMsg.content = streamContent;
          lastMsg.status = 'streaming';
        }
        return updated;
      });
    });

    eventSource.addEventListener('tool_start', (event) => {
      try {
        const data = JSON.parse(event.data);
        setMessages(prev => {
          const updated = [...prev];
          const lastMsg = updated[updated.length - 1];
          if (lastMsg && lastMsg.role === 'assistant') {
            lastMsg.toolCalls = lastMsg.toolCalls || [];
            lastMsg.toolCalls.push({ name: data.name, id: data.id, status: 'running' });
          }
          return updated;
        });
      } catch {}
    });

    eventSource.addEventListener('tool_done', (event) => {
      try {
        const data = JSON.parse(event.data);
        setMessages(prev => {
          const updated = [...prev];
          const lastMsg = updated[updated.length - 1];
          if (lastMsg?.toolCalls) {
            const tool = lastMsg.toolCalls.find(t => t.id === data.id);
            if (tool) tool.status = 'done';
          }
          return updated;
        });
      } catch {}
    });

    eventSource.addEventListener('tool_error', (event) => {
      try {
        const data = JSON.parse(event.data);
        setMessages(prev => {
          const updated = [...prev];
          const lastMsg = updated[updated.length - 1];
          if (lastMsg?.toolCalls) {
            const tool = lastMsg.toolCalls.find(t => t.id === data.id);
            if (tool) {
              tool.status = 'error';
              tool.error = data.error;
            }
          }
          return updated;
        });
      } catch {}
    });

    eventSource.addEventListener('navigate', (event) => {
      const url = event.data;
      if (url && onNavigate) {
        onNavigate(url);
      }
    });

    eventSource.addEventListener('error', (event) => {
      if (event.data) {
        setMessages(prev => {
          const updated = [...prev];
          const lastMsg = updated[updated.length - 1];
          if (lastMsg && lastMsg.role === 'assistant') {
            lastMsg.content = `Error: ${event.data}`;
            lastMsg.status = 'error';
          }
          return updated;
        });
      }
    });

    eventSource.addEventListener('done', () => {
      eventSource.close();
      eventSourceRef.current = null;
      setStreaming(false);
      setStreamStatus('');
      // Refresh undo/redo state
      loadConversation(convId);
    });

    eventSource.onerror = () => {
      eventSource.close();
      eventSourceRef.current = null;
      setStreaming(false);
      setStreamStatus('');
    };
  }, [onNavigate]);

  const handleStopStreaming = useCallback(() => {
    if (eventSourceRef.current) {
      eventSourceRef.current.close();
      eventSourceRef.current = null;
      setStreaming(false);
      setStreamStatus('');
      // Update the last message to show it was cancelled
      setMessages(prev => {
        const updated = [...prev];
        const lastMsg = updated[updated.length - 1];
        if (lastMsg && lastMsg.role === 'assistant' && lastMsg.status === 'streaming') {
          lastMsg.status = 'cancelled';
        }
        return updated;
      });
    }
  }, []);

  const handleModelChange = async (newModel) => {
    setModel(newModel);
    if (currentConversation?.id) {
      try {
        await apiPatch(`/api/admin/conversations/${currentConversation.id}`, { model: newModel });
      } catch (err) {
        console.error('Failed to update model:', err);
      }
    }
  };

  const handleUndo = async () => {
    if (!currentConversation) return;
    try {
      const result = await apiPost(`/api/admin/conversations/${currentConversation.id}/undo`);
      setCanUndo(result.canUndo);
      setCanRedo(result.canRedo);
    } catch (err) {
      console.error('Failed to undo:', err);
    }
  };

  const handleRedo = async () => {
    if (!currentConversation) return;
    try {
      const result = await apiPost(`/api/admin/conversations/${currentConversation.id}/redo`);
      setCanUndo(result.canUndo);
      setCanRedo(result.canRedo);
    } catch (err) {
      console.error('Failed to redo:', err);
    }
  };

  const handleDeleteConversation = async (id) => {
    try {
      await apiDelete(`/api/admin/conversations/${id}`);
      setConversations(prev => prev.filter(c => c.id !== id));
      if (currentConversation?.id === id) {
        // Load next conversation or clear
        const remaining = conversations.filter(c => c.id !== id);
        if (remaining.length > 0) {
          loadConversation(remaining[0].id);
        } else {
          setCurrentConversation(null);
          setMessages([]);
        }
      }
    } catch (err) {
      console.error('Failed to delete:', err);
    }
  };

  const handleKeyDown = (e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      sendMessage(inputValue);
    }
  };

  // Get models for current provider
  const availableModels = MODELS_BY_PROVIDER[aiProvider] || [];

  if (!open) return null;

  return (
    <div className="relative h-full w-[320px] lg:w-[480px] bg-base-100 border-l border-base-300">
      {/* Conversation Sidebar - Overlay */}
      {showConversations && (
        <>
          {/* Backdrop */}
          <div
            className="absolute inset-0 bg-black/20 z-10"
            onClick={() => setShowConversations(false)}
          />
          {/* Drawer */}
          <div className="absolute left-0 top-0 bottom-0 w-56 bg-base-100 border-r border-base-300 z-20 shadow-lg">
            <ConversationList
              conversations={conversations}
              current={currentConversation}
              onSelect={(id) => {
                loadConversation(id);
                setShowConversations(false);
              }}
              onNew={() => {
                createConversation();
                setShowConversations(false);
              }}
              onDelete={handleDeleteConversation}
            />
          </div>
        </>
      )}

      {/* Main Chat Area */}
      <div className="flex flex-col h-full">
        {/* AI Configuration Warning */}
        {!aiConfigured && (
          <div className="bg-warning/10 border-b border-warning/30 px-4 py-3">
            <div className="flex items-start gap-3">
              <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-warning flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
              </svg>
              <div className="flex-1 min-w-0">
                <p className="text-sm font-medium text-warning">AI not configured</p>
                <p className="text-xs text-base-content/60 mt-0.5">Add your API key to enable the AI assistant.</p>
                <Link to="/settings" className="btn btn-warning btn-xs mt-2 gap-1">
                  <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
                  </svg>
                  Configure AI
                </Link>
              </div>
            </div>
          </div>
        )}

        {/* Header */}
        <div className="flex items-center justify-between px-3 py-2 border-b border-base-300 bg-base-200/50">
          <div className="flex items-center gap-2">
            {/* Toggle sidebar button */}
            <button
              onClick={() => setShowConversations(!showConversations)}
              className="btn btn-ghost btn-sm btn-square"
              title={showConversations ? 'Hide conversations' : 'Show conversations'}
            >
              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
              </svg>
            </button>

            {/* Current conversation title */}
            <div className="flex items-center gap-2">
              <div className="w-6 h-6 rounded-lg bg-gradient-to-br from-primary to-secondary flex items-center justify-center flex-shrink-0">
                <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
                </svg>
              </div>
              <span className="font-medium text-sm truncate max-w-[120px]">
                {currentConversation?.title || 'New Chat'}
              </span>
            </div>
          </div>

        </div>

        {/* Messages */}
        <div className="flex-1 overflow-y-auto p-4">
          {messages.length > 0 ? (
            messages.map((msg, idx) => (
              <ChatMessage
                key={msg.id || idx}
                message={msg}
                streamStatus={idx === messages.length - 1 ? streamStatus : ''}
              />
            ))
          ) : (
            <EmptyState onSuggestionClick={(text) => setInputValue(text)} />
          )}
          <div ref={messagesEndRef} />
        </div>

        {/* Input */}
        <div className="p-3 border-t border-base-300 bg-base-200/30">
          {/* Attached files */}
          {attachedFiles.length > 0 && (
            <div className="flex flex-wrap gap-1 mb-2">
              {attachedFiles.map((file, idx) => (
                <div key={idx} className="badge badge-outline gap-1 pr-1">
                  <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
                  </svg>
                  <span className="text-xs max-w-[100px] truncate">{file.name}</span>
                  <button
                    onClick={() => setAttachedFiles(prev => prev.filter((_, i) => i !== idx))}
                    className="btn btn-ghost btn-xs btn-circle h-4 w-4 min-h-0"
                  >
                    <svg xmlns="http://www.w3.org/2000/svg" className="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
                    </svg>
                  </button>
                </div>
              ))}
            </div>
          )}

          {/* Hidden file input */}
          <input
            ref={fileInputRef}
            type="file"
            multiple
            className="hidden"
            onChange={(e) => {
              const files = Array.from(e.target.files || []);
              setAttachedFiles(prev => [...prev, ...files]);
              e.target.value = ''; // Reset to allow re-selecting same file
            }}
          />

          <textarea
            ref={inputRef}
            value={inputValue}
            onChange={(e) => setInputValue(e.target.value)}
            onKeyDown={handleKeyDown}
            placeholder="Message... (Enter to send)"
            className="textarea textarea-bordered w-full resize-none bg-base-100 text-sm"
            rows="2"
            disabled={sending}
          />

          {/* Controls row */}
          <div className="flex items-center justify-between mt-2">
            {/* Left: Model selector */}
            <div className="flex items-center gap-2">
              {aiConfigured && availableModels.length > 0 && (
                <select
                  value={model || availableModels[0]?.id}
                  onChange={(e) => handleModelChange(e.target.value)}
                  className="select select-bordered select-xs"
                  disabled={streaming}
                >
                  {availableModels.map(m => (
                    <option key={m.id} value={m.id}>{m.name}</option>
                  ))}
                </select>
              )}
            </div>

            {/* Right: Attach, Undo/Redo, Send */}
            <div className="flex items-center gap-1">
              {/* File attach button */}
              <button
                onClick={() => fileInputRef.current?.click()}
                className="btn btn-ghost btn-xs btn-square text-base-content/50 hover:text-base-content"
                title="Attach files"
                disabled={sending || streaming}
              >
                <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
                </svg>
              </button>

              {/* Undo */}
              {canUndo && (
                <button
                  onClick={handleUndo}
                  className="btn btn-ghost btn-xs btn-square text-base-content/50"
                  title="Undo"
                >
                  <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
                  </svg>
                </button>
              )}

              {/* Redo */}
              {canRedo && (
                <button
                  onClick={handleRedo}
                  className="btn btn-ghost btn-xs btn-square text-base-content/50"
                  title="Redo"
                >
                  <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6" />
                  </svg>
                </button>
              )}

              {/* Send/Stop button */}
              {streaming ? (
                <button
                  onClick={handleStopStreaming}
                  className="btn btn-error btn-xs btn-square"
                  title="Stop generating"
                >
                  <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
                    <rect x="6" y="6" width="12" height="12" rx="1" />
                  </svg>
                </button>
              ) : (
                <button
                  onClick={() => sendMessage(inputValue)}
                  disabled={!inputValue.trim() || sending}
                  className="btn btn-primary btn-xs btn-square"
                  title={inputValue.trim() ? "Send message" : "Type a message"}
                >
                  {sending ? (
                    <span className="loading loading-spinner loading-xs" />
                  ) : (
                    <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
                    </svg>
                  )}
                </button>
              )}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

function ChatMessage({ message, streamStatus }) {
  const isUser = message.role === 'user';
  const isStreaming = message.status === 'streaming';
  const isError = message.status === 'error';
  const isCancelled = message.status === 'cancelled';

  // Format file size
  const formatSize = (bytes) => {
    if (bytes < 1024) return `${bytes} B`;
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  };

  return (
    <div className={`mb-4 ${isUser ? 'text-right' : ''}`}>
      <div className={`inline-block max-w-[85%] rounded-lg px-4 py-2 ${
        isUser
          ? 'bg-primary text-primary-content'
          : isError
            ? 'bg-error/10 border border-error/30'
            : isCancelled
              ? 'bg-warning/10 border border-warning/30'
              : 'bg-base-200'
      }`}>
        {/* Attached files (for user messages) */}
        {message.files?.length > 0 && (
          <div className={`flex flex-wrap gap-1 mb-2 ${isUser ? 'justify-end' : ''}`}>
            {message.files.map((file) => (
              <a
                key={file.id}
                href={`/api/files/${file.id}`}
                target="_blank"
                rel="noopener noreferrer"
                className={`badge gap-1 ${isUser ? 'badge-primary-content/80 bg-primary-content/20' : 'badge-outline'}`}
              >
                <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
                </svg>
                <span className="text-xs max-w-[100px] truncate">{file.name}</span>
                <span className="text-xs opacity-60">({formatSize(file.size)})</span>
              </a>
            ))}
          </div>
        )}

        {/* Status indicator (Thinking, Rate limited, etc.) */}
        {isStreaming && streamStatus && !message.content && (
          <div className="flex items-center gap-2 text-xs text-base-content/60 mb-2">
            <span className="loading loading-dots loading-xs" />
            <span>{streamStatus}</span>
          </div>
        )}

        {/* Tool calls at top */}
        {message.toolCalls?.length > 0 && (
          <div className="mb-2 space-y-1">
            {message.toolCalls.map((tool, idx) => {
              const hasError = tool.status === 'error' || tool.error;
              const isRunning = tool.status === 'running';
              return (
                <div key={tool.id || idx} className="flex items-center gap-2 text-xs text-base-content/60">
                  {isRunning ? (
                    <span className="loading loading-spinner loading-xs" />
                  ) : hasError ? (
                    <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 text-warning" viewBox="0 0 20 20" fill="currentColor">
                      <path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
                    </svg>
                  ) : (
                    <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
                    </svg>
                  )}
                  <span>{tool.name}</span>
                  {tool.error && <span className="text-warning/80">({tool.error})</span>}
                </div>
              );
            })}
          </div>
        )}

        {/* Message content */}
        <div className="whitespace-pre-wrap text-sm">
          {message.content || (isStreaming && !streamStatus && '...')}
        </div>

        {/* Cancelled indicator */}
        {isCancelled && (
          <div className="flex items-center gap-1 mt-2 text-xs text-warning">
            <svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
            </svg>
            <span>Generation stopped</span>
          </div>
        )}
      </div>
    </div>
  );
}

function EmptyState({ onSuggestionClick }) {
  const suggestions = [
    { text: 'Create a homepage for my site', label: 'Create a homepage' },
    { text: 'Set up a blog with a posts collection and a page that lists them', label: 'Set up a blog' },
    { text: 'Add a contact page with a form for name, email, and message', label: 'Add a contact page' },
    { text: 'Create a header and footer partial for my site', label: 'Create header & footer' },
    { text: 'Show me what pages and collections are on my site', label: 'Show my site overview' },
    { text: 'Help me organize my pages into a navigation structure', label: 'Organize my pages' },
  ];

  return (
    <div className="flex flex-col items-center justify-center h-full text-center px-6">
      <div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-primary/20 to-secondary/20 flex items-center justify-center mb-4">
        <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
        </svg>
      </div>
      <h3 className="text-base font-semibold mb-1">What would you like to build?</h3>
      <p className="text-base-content/50 text-sm mb-4">
        The AI assistant can create pages, manage collections, and build your entire site.
      </p>
      <div className="grid gap-2 text-left w-full max-w-xs">
        {suggestions.map((s, idx) => (
          <button
            key={idx}
            onClick={() => onSuggestionClick(s.text)}
            className="btn btn-ghost btn-sm justify-start text-left normal-case font-normal"
          >
            <span className="text-primary">&#x2192;</span> {s.label}
          </button>
        ))}
      </div>
      <p className="text-base-content/30 text-xs mt-4">Press Cmd+K to toggle this panel</p>
    </div>
  );
}
← Back