| "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"; |
|
|
| |
| |
| |
| |
| |
| 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, |
| }); |
|
|
| |
| 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 pinned — click 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'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> |
| </> |
| ); |
| } |
|
|