← 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>
  );
}