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">→</span> {s.label}
</button>
))}
</div>
<p className="text-base-content/30 text-xs mt-4">Press Cmd+K to toggle this panel</p>
</div>
);
}