vector / client /src /components /ChatSidebar.jsx
d-ragon's picture
Upload 28 files
541216d verified
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
MessageSquare,
Plus,
Trash2,
Moon,
Sun,
Bot,
History,
FlaskConical
} from "lucide-react";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs) {
return twMerge(clsx(inputs));
}
export default function ChatSidebar({
open,
setOpen,
models,
selectedModelId,
onSelectModel,
isModelsLoading,
modelsError,
modelsIssues,
onReloadModels,
mode,
modes,
onModeChange,
onNewChat,
onClearHistory,
historyList,
activeSessionId,
onSelectSession,
isSessionLocked = false,
theme,
onToggleTheme,
activeView = "chat",
onNavigateToLabs,
onNavigateToChat,
}) {
const selectedModel = models?.find((item) => item.id === selectedModelId);
const uploadsStatus = selectedModel?.features?.status || "unknown";
const uploadsEnabled = Boolean(selectedModel?.features?.uploads);
const historyScrollRef = React.useRef(null);
React.useEffect(() => {
const el = historyScrollRef.current;
if (!el) return;
let timeoutId = null;
const onScroll = () => {
el.classList.add("scrolling");
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => el.classList.remove("scrolling"), 150);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
el.removeEventListener("scroll", onScroll);
if (timeoutId) clearTimeout(timeoutId);
};
}, []);
return (
<AnimatePresence mode="wait">
{open && (
<>
<motion.button
type="button"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-[rgba(15,17,23,0.75)] md:hidden"
onClick={() => setOpen?.(false)}
aria-label="Close sidebar overlay"
/>
<motion.aside
initial={{ x: -300, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -300, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="fixed left-0 top-0 bottom-0 z-50 w-[280px] border-r border-[rgba(255,255,255,0.04)] bg-popover flex flex-col p-4"
>
{/* Header & Logo */}
<div className="flex items-center justify-between mb-6 px-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-xl bg-foreground/5 border border-border flex items-center justify-center text-foreground">
<Bot size={18} />
</div>
<h1 className="text-xl font-display font-bold text-foreground">
Vector
</h1>
</div>
<button
onClick={() => setOpen?.(false)}
className="md:hidden p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Close sidebar"
>
<span className="text-lg leading-none">×</span>
</button>
</div>
{/* View Navigation */}
<div className="space-y-2 mb-6">
<div className="px-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Navigate
</div>
<div className="space-y-1">
<button
onClick={onNavigateToChat}
className={cn(
"flex items-center gap-3 w-full px-3 py-2.5 rounded-lg text-sm font-medium transition-colors focus-visible:outline-none",
activeView === "chat"
? "bg-foreground/10 text-foreground"
: "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
)}
>
<MessageSquare size={16} />
<span>Chat</span>
</button>
<button
onClick={onNavigateToLabs}
className={cn(
"flex items-center gap-3 w-full px-3 py-2.5 rounded-lg text-sm font-medium transition-colors focus-visible:outline-none",
activeView === "labs"
? "bg-foreground/10 text-foreground"
: "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
)}
>
<FlaskConical size={16} />
<span>Labs</span>
<span className="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-primary/20 text-primary font-medium">New</span>
</button>
</div>
</div>
{/* New Chat Button - only in chat view */}
{activeView === "chat" && (
<button
onClick={onNewChat}
className="group flex items-center gap-3 w-full px-4 py-3 rounded-xl bg-transparent border border-border transition-[background-color,border-color] duration-[120ms] ease-out mb-6 text-sm font-medium text-foreground hover:bg-foreground/5 hover:border-[var(--input-border-focus)] active:bg-foreground/8 focus-visible:outline-none"
>
<div className="bg-foreground/5 p-1.5 rounded-lg text-[#22D3EE] transition-colors group-hover:bg-foreground/8">
<Plus size={18} />
</div>
<span>New Thread</span>
<div className="ml-auto text-xs text-muted-foreground border border-border rounded px-1.5 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
Ctrl+N
</div>
</button>
)}
{/* Models Selection - only in chat view */}
{activeView === "chat" && (
<div className="mb-6 space-y-2">
<div className="px-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center justify-between">
<span>{isSessionLocked ? "Model (Locked)" : "Model"}</span>
<button
type="button"
onClick={onReloadModels}
className="text-[11px] font-medium text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 rounded px-1"
>
Refresh
</button>
</div>
<div className="relative">
<select
className={cn(
"w-full appearance-none bg-foreground/5 text-foreground text-sm rounded-lg pl-3 pr-8 py-2.5 border border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-all hover:bg-foreground/8 disabled:opacity-60",
isSessionLocked && "cursor-not-allowed opacity-70"
)}
value={selectedModelId}
onChange={(e) => onSelectModel(e.target.value)}
disabled={isModelsLoading || !models?.length || isSessionLocked}
title={isSessionLocked ? "Model is locked to this chat session. Start a new chat to change models." : ""}
>
{isModelsLoading && (
<option value="" className="bg-background">
Loading models…
</option>
)}
{!isModelsLoading && models?.length > 0 && (
<>
<option value="" disabled className="bg-background">
Select a model
</option>
{models.map((m) => (
<option key={m.id} value={m.id} className="bg-background">
{m.name}
</option>
))}
</>
)}
{!isModelsLoading && !models?.length && (
<option value="" className="bg-background">
No models configured
</option>
)}
</select>
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground">
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
{selectedModel && (
<div className="px-2 flex items-center gap-2 text-[11px]">
<span className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 font-medium",
uploadsStatus === "ok"
? uploadsEnabled
? "border-emerald-500/40 text-emerald-400 bg-emerald-500/10"
: "border-border text-muted-foreground bg-foreground/5"
: "border-amber-500/40 text-amber-400 bg-amber-500/10"
)}>
{uploadsStatus === "ok"
? uploadsEnabled
? "Uploads enabled"
: "Uploads off"
: "Uploads unknown"}
</span>
</div>
)}
{(modelsError || (modelsIssues && modelsIssues.length > 0) || (!isModelsLoading && !models?.length)) && (
<div className="px-2 text-xs text-muted-foreground space-y-1">
{modelsError && <div className="text-destructive">{modelsError}</div>}
{!modelsError && !isModelsLoading && !models?.length && (
<div>
Add MODEL_1_NAME / MODEL_1_ID / MODEL_1_HOST to your environment.
</div>
)}
{modelsIssues?.slice(0, 3).map((issue) => (
<div key={issue}>{issue}</div>
))}
</div>
)}
</div>
)}
{/* History List - only in chat view */}
{activeView === "chat" && (
<div ref={historyScrollRef} className="flex-1 overflow-y-auto -mx-2 px-2 custom-scrollbar">
<div className="mb-2 px-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<History size={12} />
Recent
</div>
<div className="space-y-1">
{historyList.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground/70 gap-2">
<MessageSquare size={24} className="opacity-20" />
<span className="text-xs">No history yet</span>
</div>
) : (
historyList.map((session, i) => (
<button
key={session.id}
onClick={() => onSelectSession(session.id)}
className={cn(
"w-full text-left px-3 py-2.5 rounded-lg text-sm transition-colors group flex items-center gap-3 relative overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 stagger-item",
activeSessionId === session.id
? "bg-[#202338] text-foreground"
: "text-muted-foreground hover:bg-[#1A1D29] hover:text-foreground"
)}
style={{ animationDelay: `${Math.min(i * 30, 300)}ms` }}
>
{activeSessionId === session.id && (
<span className="absolute left-0 top-2 bottom-2 w-[2px] bg-[#22D3EE] rounded-full" />
)}
<MessageSquare size={16} className={cn(
"shrink-0 transition-colors",
activeSessionId === session.id ? "text-foreground" : "text-muted-foreground/70 group-hover:text-muted-foreground"
)} />
<span className="truncate flex-1 z-10 relative">{session.title || "New Thread"}</span>
</button>
))
)}
</div>
</div>
)}
{/* Spacer for Labs view */}
{activeView === "labs" && <div className="flex-1" />}
{/* Footer Actions */}
<div className="pt-4 mt-4 border-t border-[rgba(255,255,255,0.04)] space-y-2">
{activeView === "chat" && (
<button
onClick={onClearHistory}
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-all group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<Trash2 size={16} />
<span>Clear History</span>
</button>
)}
<button
onClick={onToggleTheme}
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-foreground/5 hover:text-foreground transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
{theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}
<span>{theme === "dark" ? "Light Mode" : "Dark Mode"}</span>
</button>
</div>
</motion.aside>
</>
)}
</AnimatePresence>
);
}