d2l-ui / components /chat /Sidebar.tsx
Berkkirik's picture
feat: streaming + multi-turn + view source + doc-scoped + UX fixes
df790cc
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useChatStore } from "@/lib/chatStore";
import { bucketByDate, relativeTime, useTick, useIsMobile } from "@/lib/hooks";
import clsx from "clsx";
import { useEffect, useMemo, useRef, useState } from "react";
export function Sidebar() {
const pathname = usePathname();
const sidebarOpen = useChatStore((s) => s.sidebarOpen);
const list = useChatStore((s) => s.list());
const activeId = useChatStore((s) => s.activeId);
const newConversation = useChatStore((s) => s.newConversation);
const openConversation = useChatStore((s) => s.openConversation);
const deleteConversation = useChatStore((s) => s.deleteConversation);
const renameConversation = useChatStore((s) => s.renameConversation);
const setSidebarOpen = useChatStore((s) => s.setSidebarOpen);
const isMobile = useIsMobile();
useTick(30_000); // refresh "x ago" strings
const [filter, setFilter] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
// Filter + group
const groups = useMemo(() => {
const f = filter.trim().toLowerCase();
const filtered = f
? list.filter((c) => c.title.toLowerCase().includes(f))
: list;
return bucketByDate(filtered);
}, [list, filter]);
// Default sidebar to closed on mobile (only on first mount)
const didInitMobile = useRef(false);
useEffect(() => {
if (didInitMobile.current) return;
if (isMobile) setSidebarOpen(false);
didInitMobile.current = true;
}, [isMobile, setSidebarOpen]);
return (
<>
{/* Mobile scrim */}
{sidebarOpen && (
<button
aria-label="Close sidebar"
onClick={() => setSidebarOpen(false)}
className="md:hidden fixed inset-0 z-30 bg-canvas-deep/70 backdrop-blur-sm animate-fade-in"
/>
)}
<aside
className={clsx(
"z-40 flex flex-col shrink-0",
// Mobile: slide-in drawer (fixed)
"fixed top-14 bottom-0 left-0 w-[280px]",
"transition-transform duration-300 ease-atelier",
sidebarOpen ? "translate-x-0" : "-translate-x-full",
// Desktop: in flow, fills row (which is locked to viewport - topbar)
"md:relative md:top-auto md:bottom-auto md:h-full md:self-stretch",
"md:translate-x-0 md:transition-[width,opacity] md:duration-300",
sidebarOpen
? "md:w-[280px] md:opacity-100"
: "md:w-0 md:opacity-0 md:pointer-events-none"
)}
>
<div className="h-full overflow-hidden border-r border-glass-border bg-canvas-deep/70 backdrop-blur-glass flex flex-col">
{/* New chat */}
<div className="p-4">
<button
onClick={() => {
newConversation();
if (pathname !== "/") {
window.history.pushState(null, "", "/");
window.dispatchEvent(new PopStateEvent("popstate"));
}
if (isMobile) setSidebarOpen(false);
}}
className="group w-full flex items-center justify-between gap-2 px-4 py-3 rounded-md
bg-amber/10 text-amber
hover:bg-amber/15 transition-colors
text-body-strong"
style={{ boxShadow: "0 0 0 1px rgba(255,181,69,0.28)" }}
>
<span className="flex items-center gap-2.5">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M7 2v10M2 7h10"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
<span>New conversation</span>
</span>
<kbd className="hidden md:inline text-micro text-amber/70 font-mono">
⌘N
</kbd>
</button>
</div>
{/* Sections */}
<nav className="px-3 pb-3 flex flex-col gap-0.5">
<NavLink
href="/"
active={pathname === "/"}
label="Chat"
icon={<IconSpark />}
/>
<NavLink
href="/documents"
active={pathname.startsWith("/documents")}
label="Documents"
icon={<IconDoc />}
/>
<NavLink
href="/system"
active={pathname.startsWith("/system")}
label="System"
icon={<IconPulse />}
/>
</nav>
<div className="hairline mx-4 my-2" />
{/* Search + history */}
<div className="px-3 flex flex-col flex-1 min-h-0">
<div className="relative px-1 py-1">
<svg
width="13"
height="13"
viewBox="0 0 13 13"
fill="none"
className="absolute left-3 top-1/2 -translate-y-1/2 text-ink-50 pointer-events-none"
>
<circle cx="5.5" cy="5.5" r="3.5" stroke="currentColor" strokeWidth="1.4" />
<path d="m8.5 8.5 3 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Search transcripts…"
className="w-full bg-glass text-ink text-caption rounded-md pl-8 pr-3 py-2 outline-none placeholder:text-ink-30 transition-shadow"
style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.06)" }}
/>
</div>
<div className="flex-1 overflow-y-auto mt-1">
{groups.length === 0 ? (
<div className="px-3 py-4 text-caption text-ink-50">
{filter ? "No matches." : "No conversations yet."}
</div>
) : (
groups.map((g) => (
<section key={g.label} className="mb-2">
<div className="px-3 py-1.5 text-micro uppercase tracking-[0.15em] text-ink-50 font-mono">
{g.label}
</div>
<ul className="flex flex-col gap-0.5">
{g.items.map((c) => {
const active = activeId === c.id && pathname === "/";
const editing = editingId === c.id;
return (
<li
key={c.id}
className="group relative"
>
{editing ? (
<RenameInput
initial={c.title}
onCommit={(next) => {
if (next.trim()) renameConversation(c.id, next.trim());
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
/>
) : (
<button
onClick={() => {
openConversation(c.id);
if (pathname !== "/") {
window.history.pushState(null, "", "/");
window.dispatchEvent(new PopStateEvent("popstate"));
}
if (isMobile) setSidebarOpen(false);
}}
onDoubleClick={() => setEditingId(c.id)}
className={clsx(
"w-full text-left px-3 py-2 rounded-md transition-colors",
"flex items-start gap-2 min-w-0",
active
? "bg-glass-stronger text-ink"
: "text-ink-70 hover:text-ink hover:bg-glass"
)}
title="Double-click to rename"
>
<span className="text-caption truncate flex-1 min-w-0">
{c.title}
</span>
<span className="text-micro text-ink-30 font-mono shrink-0 mt-0.5">
{relativeTime(c.updatedAt)}
</span>
</button>
)}
{!editing && (
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 hidden group-hover:flex items-center gap-0.5">
<IconBtn
label="Rename"
onClick={(e) => {
e.stopPropagation();
setEditingId(c.id);
}}
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
<path
d="M2 9.2 9.2 2l1 1L3 10.2H2v-1z"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
/>
</svg>
</IconBtn>
<IconBtn
label="Delete"
danger
onClick={(e) => {
e.stopPropagation();
if (window.confirm(`Delete "${c.title}"?`)) {
deleteConversation(c.id);
}
}}
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
<path
d="M3 3l6 6M9 3l-6 6"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
</svg>
</IconBtn>
</div>
)}
</li>
);
})}
</ul>
</section>
))
)}
</div>
</div>
{/* Footer */}
<div className="px-4 py-3 border-t border-glass-border">
<a
href="https://huggingface.co/spaces/Etiya/d2l-api"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between text-micro uppercase tracking-[0.12em] text-ink-50 hover:text-ink-70 transition-colors"
>
<span>HF Space · backend</span>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path
d="M3 3h4v4M7 3 3 7"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
</a>
</div>
</div>
</aside>
</>
);
}
function NavLink({
href,
active,
label,
icon,
}: {
href: string;
active: boolean;
label: string;
icon: React.ReactNode;
}) {
return (
<Link
href={href}
className={clsx(
"flex items-center gap-3 px-3 py-2 rounded-md transition-all",
active
? "bg-glass-stronger text-ink"
: "text-ink-70 hover:text-ink hover:bg-glass"
)}
>
<span className={clsx("w-4 h-4", active && "text-amber")}>{icon}</span>
<span className="text-body">{label}</span>
</Link>
);
}
function RenameInput({
initial,
onCommit,
onCancel,
}: {
initial: string;
onCommit: (next: string) => void;
onCancel: () => void;
}) {
const [v, setV] = useState(initial);
const ref = useRef<HTMLInputElement | null>(null);
useEffect(() => {
ref.current?.focus();
ref.current?.select();
}, []);
return (
<input
ref={ref}
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={() => onCommit(v)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onCommit(v);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
className="w-full bg-glass-stronger text-ink text-caption rounded-md px-3 py-2 outline-none"
style={{ boxShadow: "0 0 0 1.5px rgba(255,181,69,0.65)" }}
/>
);
}
function IconBtn({
children,
onClick,
label,
danger,
}: {
children: React.ReactNode;
onClick: (e: React.MouseEvent) => void;
label: string;
danger?: boolean;
}) {
return (
<button
onClick={onClick}
title={label}
aria-label={label}
className={clsx(
"p-1 rounded transition-colors",
danger
? "text-ink-50 hover:text-status-err hover:bg-status-err-glow"
: "text-ink-50 hover:text-ink hover:bg-glass"
)}
>
{children}
</button>
);
}
function IconSpark() {
return (
<svg viewBox="0 0 16 16" fill="none" className="w-full h-full">
<path
d="M8 1.5v3M8 11.5v3M1.5 8h3M11.5 8h3M3.5 3.5l2 2M10.5 10.5l2 2M3.5 12.5l2-2M10.5 5.5l2-2"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
</svg>
);
}
function IconDoc() {
return (
<svg viewBox="0 0 16 16" fill="none" className="w-full h-full">
<path
d="M3.5 1.5h6l3.5 3.5v9a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5v-12a.5.5 0 0 1 .5-.5z"
stroke="currentColor"
strokeWidth="1.4"
/>
<path d="M9.5 1.5V5h3.5" stroke="currentColor" strokeWidth="1.4" />
</svg>
);
}
function IconPulse() {
return (
<svg viewBox="0 0 16 16" fill="none" className="w-full h-full">
<path
d="M1.5 8h3l1.5-4 3 8L10.5 8h4"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}