d2l-ui / components /chat /SourceDrawer.tsx
Berkkirik's picture
feat: streaming + multi-turn + view source + doc-scoped + UX fixes
df790cc
"use client";
import { api } from "@/lib/api";
import { useChatStore } from "@/lib/chatStore";
import { useQuery } from "@tanstack/react-query";
import clsx from "clsx";
import { useEffect } from "react";
import ReactMarkdown from "react-markdown";
/**
* View-source drawer. Shown when the user clicks a source chip or an inline
* citation. Renders the full markdown of a doc, plus the option to "Pin this
* source" — which scopes follow-up questions to that doc via /ask.
*/
export function SourceDrawer({
open,
docId,
docName,
onClose,
}: {
open: boolean;
docId: string | null;
docName?: string | null;
onClose: () => void;
}) {
const activeId = useChatStore((s) => s.activeId);
const conv = useChatStore((s) => (s.activeId ? s.conversations[s.activeId] : null));
const pinDoc = useChatStore((s) => s.pinDoc);
const newConversationPinned = useChatStore((s) => s.newConversationPinned);
const { data, isLoading, error } = useQuery({
queryKey: ["doc-content", docId],
queryFn: () => api.getDocumentContent(docId as string),
enabled: open && !!docId,
staleTime: 60_000,
});
// ESC to close
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
const isPinned = conv?.pinnedDocId === docId;
const cleanName = (n: string | null | undefined) =>
(n ?? "").replace(/_\d{6,}$/, "");
const displayName = data?.name ? cleanName(data.name) : cleanName(docName);
const handlePin = () => {
if (!docId || !activeId) return;
if (isPinned) pinDoc(activeId, null, null);
else pinDoc(activeId, docId, data?.name ?? docName ?? null);
};
const handleNewScoped = () => {
if (!docId) return;
newConversationPinned(docId, data?.name ?? docName ?? "");
onClose();
};
return (
<>
{/* Scrim */}
<div
className={clsx(
"fixed inset-0 z-40 bg-canvas-deep/60 backdrop-blur-sm transition-opacity duration-300",
open ? "opacity-100" : "opacity-0 pointer-events-none"
)}
onClick={onClose}
aria-hidden
/>
<aside
className={clsx(
"fixed top-0 right-0 z-50 h-screen w-full sm:w-[560px] glass-strong",
"flex flex-col transition-transform duration-300 ease-atelier",
open ? "translate-x-0" : "translate-x-full"
)}
role="dialog"
aria-modal="true"
aria-label="Source document"
>
{/* Header */}
<header className="px-6 py-5 border-b border-glass-border">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="text-micro uppercase tracking-[0.16em] text-ink-50 font-mono mb-1">
Source · view
</div>
<h2
className="text-display-sm text-ink truncate"
title={displayName ?? ""}
>
{displayName || "Loading…"}
</h2>
{docId && (
<div className="text-micro font-mono text-ink-50 mt-0.5 truncate">
{docId}
</div>
)}
</div>
<button
onClick={onClose}
className="shrink-0 p-2 rounded-md text-ink-50 hover:text-ink hover:bg-glass transition-colors"
aria-label="Close"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="m3 3 8 8M11 3l-8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
{/* Pin / scope actions */}
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
onClick={handlePin}
disabled={!activeId || !docId}
className={clsx(
"text-caption px-3 py-1.5 rounded-pill transition-colors disabled:opacity-40",
isPinned
? "bg-amber text-canvas hover:bg-amber-soft"
: "bg-glass text-ink hover:bg-glass-stronger"
)}
style={
isPinned
? undefined
: { boxShadow: "0 0 0 1px rgba(255,255,255,0.10)" }
}
title={
isPinned
? "Currently pinnedclick to unpin"
: "Pin: future questions in this conversation will use only this doc"
}
>
{isPinned ? "✓ Pinned to this conversation" : "Pin to this conversation"}
</button>
<button
onClick={handleNewScoped}
disabled={!docId}
className="text-caption px-3 py-1.5 rounded-pill bg-glass text-ink hover:bg-glass-stronger transition-colors disabled:opacity-40"
style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.10)" }}
title="Open a new conversation scoped to this doc"
>
New scoped conversation →
</button>
</div>
</header>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{isLoading && (
<div className="text-ink-50 text-shimmer">Loading source…</div>
)}
{error && (
<div
className="p-4 rounded-md"
style={{
background: "rgba(255, 122, 122, 0.06)",
boxShadow: "0 0 0 1px rgba(255, 122, 122, 0.28)",
}}
>
<div className="text-caption-strong text-status-err">
Couldn&apos;t load source
</div>
<div className="text-caption text-ink-70 mt-1">
{error instanceof Error ? error.message : String(error)}
</div>
</div>
)}
{data && (
<article className="markdown-body text-ink leading-[1.7]">
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-3 last:mb-0">{children}</p>,
h1: ({ children }) => (
<h2 className="text-display-md text-ink mt-6 mb-3">{children}</h2>
),
h2: ({ children }) => (
<h3 className="text-display-sm text-ink mt-5 mb-2.5">{children}</h3>
),
h3: ({ children }) => (
<h4 className="text-body-strong text-ink mt-4 mb-2">{children}</h4>
),
ul: ({ children }) => (
<ul className="list-disc pl-5 mb-3 space-y-1 marker:text-amber/70">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-5 mb-3 space-y-1 marker:text-amber/70 marker:font-mono">
{children}
</ol>
),
strong: ({ children }) => (
<strong className="font-semibold text-ink">{children}</strong>
),
em: ({ children }) => (
<em className="serif-italic text-amber-soft">{children}</em>
),
code: ({ children }) => (
<code
className="font-mono text-amber px-1.5 py-0.5 rounded text-[0.92em]"
style={{ background: "rgba(255,181,69,0.10)" }}
>
{children}
</code>
),
pre: ({ children }) => (
<pre
className="font-mono text-caption p-3 rounded-md overflow-x-auto my-3"
style={{
background: "rgba(0,0,0,0.28)",
boxShadow: "0 0 0 1px rgba(255,255,255,0.06)",
}}
>
{children}
</pre>
),
a: ({ href, children }) => (
<a
href={href}
className="text-amber underline decoration-amber/40 hover:decoration-amber"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
hr: () => <hr className="my-5 border-0 h-px bg-glass-border" />,
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-amber/50 pl-4 my-3 text-ink-70 serif-italic">
{children}
</blockquote>
),
}}
>
{data.text}
</ReactMarkdown>
</article>
)}
</div>
{data && (
<footer className="px-6 py-3 border-t border-glass-border text-micro font-mono uppercase tracking-[0.12em] text-ink-50">
{data.length_chars.toLocaleString()} chars
{data.created_at
? ` · ${new Date(data.created_at * 1000).toLocaleDateString()}`
: ""}
</footer>
)}
</aside>
</>
);
}