← back to Norma
components/ChatBar.tsx
327 lines
'use client';
import { useState, useRef, useCallback, type KeyboardEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Sparkles, Send, ChevronUp, ChevronDown, X, Check,
AlertTriangle, Loader2,
} from 'lucide-react';
import { useToast } from '@/components/ToastProvider';
/* ─── Types ─────────────────────────────────────────────────────────────── */
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
created_at: string;
action?: ActionResult | null;
}
interface ActionResult {
type: string;
success: boolean;
summary: string;
details?: string;
}
/* ─── ChatBar ───────────────────────────────────────────────────────────── */
export default function ChatBar() {
const { addToast } = useToast();
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [expanded, setExpanded] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
setTimeout(() => messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 50);
}, []);
/* ── Send message ─────────────────────────────────────────────────────── */
async function sendMessage() {
const text = input.trim();
if (!text || loading) return;
const userMsg: ChatMessage = {
id: `u-${Date.now()}`,
role: 'user',
content: text,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMsg]);
setInput('');
setLoading(true);
setExpanded(true);
if (textareaRef.current) textareaRef.current.style.height = 'auto';
try {
const res = await fetch('/api/ai-chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ message: text }),
});
const data = await res.json();
if (res.ok && data.reply) {
const aiMsg: ChatMessage = {
id: `a-${Date.now()}`,
role: 'assistant',
content: data.reply,
created_at: new Date().toISOString(),
action: data.action || null,
};
setMessages((prev) => [...prev, aiMsg]);
if (data.action?.success) {
addToast(data.action.summary, 'success');
}
} else {
setMessages((prev) => [...prev, {
id: `e-${Date.now()}`,
role: 'assistant',
content: data.error || 'Something went wrong.',
created_at: new Date().toISOString(),
}]);
}
} catch {
setMessages((prev) => [...prev, {
id: `e-${Date.now()}`,
role: 'assistant',
content: 'Network error. Please try again.',
created_at: new Date().toISOString(),
}]);
} finally {
setLoading(false);
scrollToBottom();
setTimeout(() => textareaRef.current?.focus(), 50);
}
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
function handleTextareaChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
setInput(e.target.value);
const ta = e.target;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 72) + 'px';
}
const recentMessages = messages.slice(-20);
const hasMessages = messages.length > 0;
return (
<div style={{
borderTop: '1px solid var(--color-border)',
backgroundColor: 'var(--color-surface)',
flexShrink: 0,
}}>
{/* ── Expandable message history ───────────────────────────────────── */}
<AnimatePresence>
{expanded && hasMessages && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
style={{ overflow: 'hidden' }}
>
<div style={{
maxHeight: 300,
overflowY: 'auto',
padding: '12px 16px',
display: 'flex',
flexDirection: 'column',
gap: 8,
borderBottom: '1px solid var(--color-border)',
}}>
{recentMessages.map((msg) => (
<div
key={msg.id}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: msg.role === 'user' ? 'flex-end' : 'flex-start',
}}
>
<div style={{
maxWidth: '80%',
padding: '8px 12px',
fontSize: 13,
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
...(msg.role === 'user'
? {
backgroundColor: '#10b981',
color: '#fff',
borderRadius: '12px 12px 4px 12px',
}
: {
backgroundColor: 'var(--color-surface-el)',
color: 'var(--color-text)',
borderRadius: '12px 12px 12px 4px',
}),
}}>
{msg.content}
</div>
{/* Action result badge */}
{msg.action && (
<div style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
marginTop: 4,
padding: '3px 8px',
borderRadius: 6,
fontSize: 11,
fontWeight: 600,
backgroundColor: msg.action.success
? 'rgba(16,185,129,0.12)'
: 'rgba(239,68,68,0.12)',
color: msg.action.success ? '#10b981' : '#ef4444',
border: `1px solid ${msg.action.success ? 'rgba(16,185,129,0.25)' : 'rgba(239,68,68,0.25)'}`,
}}>
{msg.action.success ? <Check size={10} /> : <AlertTriangle size={10} />}
{msg.action.summary}
</div>
)}
</div>
))}
{loading && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 0' }}>
<Loader2 size={14} className="animate-spin" style={{ color: 'var(--color-primary)' }} />
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>Thinking...</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
</motion.div>
)}
</AnimatePresence>
{/* ── Input bar (always visible) ──────────────────────────────────── */}
<div style={{
display: 'flex',
alignItems: 'flex-end',
gap: 8,
padding: '8px 16px',
}}>
{/* Toggle history button */}
{hasMessages && (
<button
type="button"
onClick={() => setExpanded(!expanded)}
title={expanded ? 'Hide chat history' : 'Show chat history'}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4,
color: 'var(--color-text-muted)',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
}}
>
{expanded ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
</button>
)}
{/* Sparkle icon */}
<Sparkles size={16} style={{
color: 'var(--color-primary)',
flexShrink: 0,
marginBottom: 8,
opacity: 0.7,
}} />
{/* Input */}
<textarea
ref={textareaRef}
value={input}
onChange={handleTextareaChange}
onKeyDown={handleKeyDown}
placeholder="Tell Norma what to update..."
disabled={loading}
rows={1}
style={{
flex: 1,
resize: 'none',
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '7px 12px',
fontSize: 13,
lineHeight: 1.4,
backgroundColor: 'var(--color-bg)',
color: 'var(--color-text)',
outline: 'none',
overflow: 'hidden',
transition: 'border-color 0.15s',
maxHeight: 72,
}}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--color-primary)'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--color-border)'; }}
/>
{/* Send button */}
<button
type="button"
onClick={sendMessage}
disabled={loading || !input.trim()}
title="Send (Enter)"
style={{
width: 32,
height: 32,
borderRadius: 8,
border: 'none',
cursor: loading || !input.trim() ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: loading || !input.trim() ? 'var(--color-surface-el)' : 'var(--color-primary)',
transition: 'background-color 0.15s',
flexShrink: 0,
}}
>
{loading
? <Loader2 size={14} className="animate-spin" style={{ color: 'var(--color-text-muted)' }} />
: <Send size={14} color={!input.trim() ? 'var(--color-text-muted)' : '#fff'} />
}
</button>
{/* Clear chat */}
{hasMessages && (
<button
type="button"
onClick={() => { setMessages([]); setExpanded(false); }}
title="Clear chat"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4,
color: 'var(--color-text-muted)',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
opacity: 0.5,
}}
>
<X size={14} />
</button>
)}
</div>
</div>
);
}