finai / apps /web /components /shell /ChatShell.tsx
Sandy-sys's picture
Deploy FinAI
db7ef8e verified
Raw History Blame Contribute Delete
12.9 kB
"use client";
import { useEffect, useState, useRef } from "react";
import Image from "next/image";
import { Send, History, Sparkles, Trash2, MessageSquare, Clock, ChevronLeft, Menu, X, Activity, Database } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import type { UIPlan } from "@/components/dynamic/types";
type Health = {
status: string;
duckdb_ok: boolean;
tables: number;
llm_keys: number;
mode: string;
};
type HistoryEntry = { q: string; plan: UIPlan; ts: number };
type Props = {
history: HistoryEntry[];
onAsk: (q: string) => void;
onSelect: (e: HistoryEntry) => void;
onClear?: () => void;
loading: boolean;
children: React.ReactNode;
liveMode?: boolean;
onToggleLive?: () => void;
onOpenKB?: () => void;
};
export function ChatShell({ history, onAsk, onSelect, onClear, loading, children, liveMode, onToggleLive, onOpenKB }: Props) {
const [q, setQ] = useState("");
const [sidebarOpen, setSidebarOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const submit = () => {
if (!q.trim() || loading) return;
onAsk(q);
setQ("");
};
return (
<div className="grid min-h-screen max-w-full overflow-x-hidden grid-cols-1 lg:grid-cols-[280px_1fr]">
{/* Mobile sidebar overlay */}
<AnimatePresence>
{sidebarOpen && (
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-40 bg-black/30 backdrop-blur-sm lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
</AnimatePresence>
<Sidebar
history={history}
onSelect={(e) => { onSelect(e); setSidebarOpen(false); }}
onClear={onClear}
mobileOpen={sidebarOpen}
onCloseMobile={() => setSidebarOpen(false)}
liveMode={liveMode}
onToggleLive={onToggleLive ? () => { onToggleLive(); setSidebarOpen(false); } : undefined}
onOpenKB={onOpenKB ? () => { onOpenKB(); setSidebarOpen(false); } : undefined}
/>
<main className="flex min-h-screen flex-col">
<Topbar onMenuClick={() => setSidebarOpen(true)} />
<section className={`flex-1 overflow-x-hidden ${liveMode ? "" : "px-4 py-6 sm:px-6"}`}>{children}</section>
{/* ── Enhanced Input Bar ──────────────────────────────── */}
<div className="sticky bottom-0 z-10 border-t border-mb-mist bg-white/85 px-4 py-3 backdrop-blur-lg">
<form
onSubmit={(e) => { e.preventDefault(); submit(); }}
className="mx-auto max-w-4xl"
>
<div className="flex items-center gap-3 rounded-2xl border border-mb-amber-200/80 bg-white px-4 py-2 shadow-lg shadow-mb-orange/5 ring-1 ring-mb-orange/10 transition-shadow focus-within:shadow-xl focus-within:shadow-mb-orange/10 focus-within:ring-mb-orange/20">
<Sparkles className="h-4 w-4 shrink-0 text-mb-orange" />
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Ask about budgets, programs, regional trends…"
className="h-10 flex-1 bg-transparent text-[15px] text-mb-ink outline-none placeholder:text-mb-slate/40"
suppressHydrationWarning
autoComplete="off"
/>
<div className="flex items-center gap-2">
{q.trim() && (
<span className="hidden sm:inline text-[10px] text-mb-slate/40 font-mono">
↡ Enter
</span>
)}
<button
type="submit"
disabled={loading || !q.trim()}
suppressHydrationWarning
className="flex h-9 items-center gap-2 rounded-xl bg-gradient-to-r from-mb-orange to-mb-yellow px-4 text-sm font-semibold text-white shadow-lg shadow-mb-orange/30 transition-all hover:brightness-105 hover:shadow-xl disabled:opacity-40 disabled:shadow-none"
>
<Send className="h-4 w-4" />
<span className="hidden sm:inline">{loading ? "Thinking…" : "Ask"}</span>
</button>
</div>
</div>
<div className="mt-1.5 flex justify-center">
<span className="text-[10px] text-mb-slate/35">
Results include provenance citations from your database Β· No hallucinated data
</span>
</div>
</form>
</div>
</main>
</div>
);
}
function Sidebar({
history,
onSelect,
onClear,
mobileOpen,
onCloseMobile,
liveMode,
onToggleLive,
onOpenKB,
}: Pick<Props, "history" | "onSelect" | "onClear" | "liveMode" | "onToggleLive" | "onOpenKB"> & {
mobileOpen: boolean;
onCloseMobile: () => void;
}) {
return (
<aside className={`
${mobileOpen ? "fixed inset-y-0 left-0 z-50 w-72 shadow-2xl" : "hidden"}
border-r border-[#D4A11E]/35 bg-gradient-to-b from-[#F1C232] via-[#F6D76B] to-[#FFF1B8] p-4 backdrop-blur
lg:relative lg:flex lg:flex-col lg:z-auto lg:w-auto lg:shadow-none
`}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Image
src="/magic-bus-logo.png"
alt="Magic Bus India Foundation"
width={36}
height={36}
className="rounded-lg shadow-sm"
priority
/>
<div>
<div className="font-serif text-lg leading-none">FinAI</div>
<div className="text-[10px] uppercase tracking-[0.12em] text-mb-slate/55">
Adaptive Analytics
</div>
</div>
</div>
<button onClick={onCloseMobile} title="Close sidebar" className="rounded-md p-1 lg:hidden hover:bg-white/30">
<X className="h-4 w-4" />
</button>
</div>
{/* Live Insights toggle */}
{onToggleLive && (
<button
type="button"
onClick={onToggleLive}
suppressHydrationWarning
className={`mt-4 flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold transition-all ${
liveMode
? "bg-gradient-to-r from-emerald-500 to-teal-600 text-white shadow-md"
: "bg-white/50 text-mb-ink/80 ring-1 ring-mb-amber-200/40 hover:bg-white/70 hover:shadow-sm"
}`}
>
<Activity className="h-4 w-4" />
Live Insights
<span className="relative ml-auto flex h-2.5 w-2.5">
<span className={`absolute inline-flex h-full w-full rounded-full opacity-75 ${liveMode ? "animate-ping bg-white" : "bg-emerald-400"}`} />
<span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${liveMode ? "bg-white" : "bg-emerald-500"}`} />
</span>
</button>
)}
{/* Knowledge Base manager */}
{onOpenKB && (
<button
type="button"
onClick={onOpenKB}
suppressHydrationWarning
className="mt-2 flex w-full items-center gap-2 rounded-xl bg-white/50 px-3 py-2.5 text-sm font-semibold text-mb-ink/80 ring-1 ring-mb-amber-200/40 transition-all hover:bg-white/70 hover:shadow-sm"
>
<Database className="h-4 w-4 text-mb-amber-700" />
Knowledge Base
<span className="ml-auto rounded-full bg-mb-amber-100 px-2 py-0.5 text-[10px] font-medium text-mb-amber-800">
Edit
</span>
</button>
)}
{/* History section */}
<div className="mt-6 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-mb-slate/55 font-semibold">
<Clock className="h-3 w-3" />
History
{history.length > 0 && (
<span className="ml-1 rounded-full bg-white/50 px-1.5 py-0.5 text-[9px] font-bold text-mb-amber-800">
{history.length}
</span>
)}
</div>
{history.length > 0 && onClear && (
<button
onClick={onClear}
title="Clear history"
className="rounded-md p-1 text-mb-slate/50 transition hover:bg-white/40 hover:text-rose-700"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
<ul className="mt-2 flex-1 space-y-0.5 overflow-y-auto pr-1 scrollbar-thin">
{history.length === 0 ? (
<li className="rounded-xl bg-[#F1C232]/15 p-3 text-[12px] text-mb-slate/60 leading-relaxed">
<MessageSquare className="mb-1 h-4 w-4 text-mb-amber-700/40" />
Your analysis history will appear here. Saved locally on this device.
</li>
) : (
history.map((h, i) => (
<li key={`${h.q}-${i}`}>
<button
onClick={() => onSelect(h)}
className="group/h w-full rounded-xl px-2.5 py-2 text-left transition-all hover:bg-gradient-to-r hover:from-[#F9E08B]/80 hover:to-[#FFF3C2]/80 hover:shadow-sm"
title={h.q}
>
<div className="truncate text-[13px] font-medium text-mb-ink/90 group-hover/h:text-mb-amber-900">
{h.q}
</div>
{h.ts && (
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-mb-slate/45 group-hover/h:text-mb-amber-700/60">
<Clock className="h-2.5 w-2.5" />
{fmtTime(h.ts)}
</div>
)}
</button>
</li>
))
)}
</ul>
</aside>
);
}
function fmtTime(ts: number): string {
const d = new Date(ts);
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
return sameDay
? d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
: d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) +
" Β· " +
d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
}
function Topbar({ onMenuClick }: { onMenuClick: () => void }) {
const [h, setH] = useState<Health | null>(null);
useEffect(() => {
let alive = true;
const load = () =>
fetch("/api/backend/health")
.then((r) => (r.ok ? r.json() : null))
.then((j) => alive && setH(j))
.catch(() => alive && setH(null));
load();
const id = setInterval(load, 15000);
return () => {
alive = false;
clearInterval(id);
};
}, []);
const tables = h?.tables ?? 0;
const keys = h?.llm_keys ?? 0;
const mode = h?.mode ?? "…";
const dbOk = h?.duckdb_ok;
const loadingHealth = h === null;
const dotColor = loadingHealth
? "bg-yellow-400 animate-pulse"
: dbOk && keys > 0
? "bg-emerald-400"
: "bg-rose-400";
return (
<header className="flex items-center justify-between border-b border-[#D4A11E]/35 bg-gradient-to-r from-[#F1C232] via-[#F6D76B] to-[#FFF1B8] px-4 py-2.5 backdrop-blur sm:px-6">
<div className="flex items-center gap-3">
<button onClick={onMenuClick} title="Open sidebar" className="rounded-md p-1 lg:hidden hover:bg-white/30">
<Menu className="h-5 w-5 text-mb-ink/70" />
</button>
<div className="flex items-center gap-2 text-sm text-mb-slate">
<span className={`h-2 w-2 rounded-full ${dotColor}`} />
<span className="font-semibold text-mb-orange">finai</span>
<span className="hidden sm:inline text-mb-slate/40">Β·</span>
<span className="hidden sm:inline text-[13px]">
{loadingHealth
? "connecting…"
: dbOk === false
? "database unavailable"
: `${tables} tables Β· Llama 3.3`}
</span>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
{!loadingHealth && keys > 0 && (
<span className="hidden sm:inline rounded-full bg-emerald-100/70 px-2 py-0.5 text-[10px] font-medium text-emerald-800 ring-1 ring-emerald-300/30">
{keys} keys active
</span>
)}
<span className="rounded-full bg-mb-amber-100/80 px-2 py-0.5 text-[10px] font-semibold text-mb-amber-800 ring-1 ring-mb-amber-200/60">
FY 2025–26
</span>
</div>
</header>
);
}