import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; import { Link, useParams, useSearchParams } from "react-router-dom"; import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints"; import { streamSSE } from "../api/stream"; import type { AnswerFeedback as Feedback, Artifact, ChatSummary, Citation, MemoryChange, Notebook } from "../api/types"; import { AnswerFeedback } from "../components/AnswerFeedback"; import { ArtifactCard, CitationList } from "../components/ArtifactCard"; import { Markdown } from "../components/Markdown"; import { DocPicker } from "../components/DocPicker"; import { TelescopeIcon } from "../components/icons/Icons"; import { MindMapDialog, MindMapExplorer, MindMapRow } from "../components/MindMap"; import { Reader } from "../components/Reader"; import { ConfirmButton, errorMessage, formatDate, Loading, Modal, Tabs } from "../components/ui"; type Turn = { id?: string; // the saved message, once there is one: what a thumbs up or down is attached to feedback?: Feedback | null; role: "user" | "assistant"; text: string; citations?: Citation[]; status?: string; steps?: string[]; saved?: MemoryChange[]; }; type Pane = "create" | "posts"; const STARTERS = ["What are the strongest ideas across these posts?", "Where do I contradict myself?", "Which post is most worth updating, and why?"]; const CHAT_RAIL_KEY = "ns_notebook_chat_rail"; function readChatRailOpen(): boolean { try { return localStorage.getItem(CHAT_RAIL_KEY) !== "closed"; } catch { return true; } } /** The Create tab: one click per format with sensible defaults, options folded away. */ function StudioPanel({ notebookId, docs, disabled }: { notebookId: string; docs: { id: string; title: string }[]; disabled: boolean }) { const [artifacts, setArtifacts] = useState(null); const [format, setFormat] = useState<"deep_dive" | "brief" | "debate">("deep_dive"); const [minutes, setMinutes] = useState(6); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null); const [mapDialog, setMapDialog] = useState(false); const [mapBusy, setMapBusy] = useState(false); const [mapError, setMapError] = useState(null); const [waitingMap, setWaitingMap] = useState(null); // a map the user just asked for: open it when ready const [exploring, setExploring] = useState(null); const load = useCallback(() => notebooksApi.artifacts(notebookId).then(setArtifacts), [notebookId]); useEffect(() => { load(); }, [load]); const make = async (body: Omit) => { setBusy(true); setError(null); try { const a = await artifactsApi.generate({ ...body, notebook_id: notebookId }); setArtifacts((list) => [a, ...(list ?? [])]); } catch (e) { setError(errorMessage(e)); } finally { setBusy(false); } }; const makeMap = async (documentIds: string[], focus: string) => { setMapBusy(true); setMapError(null); try { const a = await artifactsApi.generate({ type: "mind_map", notebook_id: notebookId, document_ids: documentIds.length === docs.length ? undefined : documentIds, focus: focus || undefined, }); setArtifacts((list) => [a, ...(list ?? [])]); setWaitingMap(a.id); setMapDialog(false); } catch (e) { setMapError(errorMessage(e)); } finally { setMapBusy(false); } }; const maps = artifacts?.filter((a) => a.type === "mind_map") ?? []; const others = artifacts?.filter((a) => a.type !== "mind_map"); const cite = (c: Citation) => c.document_id && setReading({ id: c.document_id, start: c.line_start, end: c.line_end }); const off = disabled || busy; return (
Audio options
{disabled &&

Add posts to this notebook to start creating.

} {error &&

{error}

} {maps.length > 0 && (

Mind Constellations {maps.length}

{maps.map((a) => ( { setWaitingMap(null); setExploring(m); }} onRemoved={(id) => setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))} /> ))}
)}
{!artifacts && } {others?.map((a) => ( setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))} actions={(art) => art.type === "audio_overview" && art.status === "ready" ? ( ) : null } /> ))}
{reading && setReading(null)} />} {mapDialog && setMapDialog(false)} onCreate={makeMap} />} {exploring && setExploring(null)} />}
); } export default function NotebookView() { const { id = "" } = useParams(); const [nb, setNb] = useState(null); const [loadError, setLoadError] = useState(null); const [turns, setTurns] = useState([]); const [q, setQ] = useState(""); const [chats, setChats] = useState([]); const [busy, setBusy] = useState(false); const [loadingChatId, setLoadingChatId] = useState(null); const [adding, setAdding] = useState(false); const [toAdd, setToAdd] = useState([]); const [addError, setAddError] = useState(null); const [reading, setReading] = useState<{ id: string; start?: number; end?: number } | null>(null); const [quoted, setQuoted] = useState(null); // a chat citation: the earlier words, shown in a popup const [editingTitle, setEditingTitle] = useState(false); const [title, setTitle] = useState(""); const [pane, setPane] = useState("create"); const [historyOpen, setHistoryOpen] = useState(readChatRailOpen); const [params, setParams] = useSearchParams(); // The open chat lives in the URL (?chat=), not only in React state: a state reset, a reload or a hot reload can no // longer turn the next message into a new chat. `shown` is the chat whose messages are on screen right now. const chatId = params.get("chat"); const shown = useRef(null); const chatLoad = useRef(0); const asked = useRef(false); const bottom = useRef(null); // Auto-save the title shortly after the user stops typing. useEffect(() => { if (!editingTitle || !nb) return; const next = title.trim(); if (!next || next === nb.title) return; const t = setTimeout(() => { notebooksApi.update(nb.id, { title: next }).then(() => setNb((cur) => (cur ? { ...cur, title: next } : cur))); }, 800); return () => clearTimeout(t); }, [title, editingTitle, nb]); const load = useCallback( () => notebooksApi.get(id).then( (n) => { setNb(n); setTitle(n.title); }, () => setLoadError("Notebook not found."), ), [id], ); const loadChats = useCallback(() => notebooksApi.chats(id).then(setChats), [id]); useEffect(() => { load(); loadChats(); }, [load, loadChats]); // A different notebook starts with an empty transcript. (Its URL has no ?chat, so no chat is selected either.) useEffect(() => { chatLoad.current += 1; setLoadingChatId(null); setTurns([]); shown.current = null; }, [id]); useEffect(() => { bottom.current?.scrollIntoView({ block: "end" }); }, [turns]); /** Select a chat, or none, by writing it into the URL. Other query params (like ?q) are left alone. */ const selectChat = (cid: string | null) => setParams( (p) => { const next = new URLSearchParams(p); if (cid) next.set("chat", cid); else next.delete("chat"); return next; }, { replace: true }, ); const openChat = async (cid: string) => { const request = ++chatLoad.current; shown.current = cid; selectChat(cid); setLoadingChatId(cid); setTurns([]); let msgs; try { msgs = await notebooksApi.messages(cid); } catch { if (request !== chatLoad.current) return; // A chat that is gone (deleted, or from another account): fall back to a clean new chat. shown.current = null; selectChat(null); setTurns([]); return; } finally { if (request === chatLoad.current) setLoadingChatId(null); } // If the user selected another conversation while this request was running, ignore this older response. if (request !== chatLoad.current) return; const history: Turn[] = msgs.map((m) => ({ id: m.id, feedback: m.feedback, role: m.role, text: m.text, citations: m.citations })); // Early chats saved the answer but not the opening user message. Their title is the original question, // so restore it in the transcript instead of showing an assistant answer with no visible prompt. if (history[0]?.role === "assistant") { const openingQuestion = chats.find((c) => c.id === cid)?.title?.trim(); if (openingQuestion) history.unshift({ role: "user", text: openingQuestion }); } setTurns(history); }; // A chat named in the URL that is not on screen yet (a reload, the back button, a shared link) is opened. useEffect(() => { if (chatId && chatId !== shown.current) void openChat(chatId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [chatId]); const newChat = () => { chatLoad.current += 1; setLoadingChatId(null); shown.current = null; selectChat(null); setTurns([]); setQ(""); }; const removeChat = async (cid: string) => { await notebooksApi.removeChat(cid); if (cid === chatId) newChat(); await loadChats(); }; const setChatRail = (open: boolean) => { setHistoryOpen(open); try { localStorage.setItem(CHAT_RAIL_KEY, open ? "open" : "closed"); } catch { /* Private browsing can prevent persistence; the control still works for this visit. */ } }; const ask = async (text: string) => { const question = text.trim(); if (!question || busy || loadingChatId) return; setQ(""); setBusy(true); setTurns((t) => [...t, { role: "user", text: question }, { role: "assistant", text: "", status: "Scanning" }]); const patchLast = (p: Partial) => setTurns((t) => [...t.slice(0, -1), { ...t[t.length - 1], ...p }]); const answerAt = turns.length + 1; // where this answer sits, so a late "saved" note lands on the right message let answered = ""; try { await streamSSE( `/api/notebooks/${id}/chat`, (event, data) => { const d = data as Record; if (event === "status") { if (d.chat_id) { shown.current = d.chat_id as string; // its messages are already on screen: do not reload them selectChat(d.chat_id as string); } patchLast({ status: d.message as string }); } else if (event === "step") { setTurns((t) => { const last = t[t.length - 1]; return [...t.slice(0, -1), { ...last, status: d.message as string, steps: [...(last.steps ?? []), d.message as string] }]; }); } else if (event === "error") { patchLast({ text: d.message as string, status: undefined }); } else if (event === "answer") { answered = d.text as string; patchLast({ id: d.message_id as string, text: answered, citations: d.citations as Citation[], status: undefined }); setBusy(false); // the answer is here; the "saved to memory" note may follow a moment later } else if (event === "memory") { const saved = d.saved as MemoryChange[]; setTurns((t) => t.map((x, i) => (i === answerAt && x.role === "assistant" && x.text === answered ? { ...x, saved } : x))); } }, // Read at send time from the URL, the one place a stale closure or a state reset cannot change. { method: "POST", body: JSON.stringify({ question, chat_id: new URLSearchParams(window.location.search).get("chat") }) }, ); } catch { patchLast({ text: "Lost signal. Try again.", status: undefined }); } finally { setBusy(false); loadChats(); } }; // A question passed in the link (from the Home ask box) is asked as soon as the notebook opens. useEffect(() => { const pending = params.get("q"); if (!nb || !pending || asked.current || !nb.documents.length) return; asked.current = true; setParams( (p) => { const next = new URLSearchParams(p); next.delete("q"); return next; }, { replace: true }, ); void ask(pending); // eslint-disable-next-line react-hooks/exhaustive-deps }, [nb]); if (loadError) return

{loadError}

; if (!nb) return ; const cite = (c: Citation) => { if (c.kind === "chat") setQuoted(c); else if (c.document_id) setReading({ id: c.document_id, start: c.line_start, end: c.line_end }); else window.open(c.url, "_blank"); }; return (
{historyOpen && }
{!historyOpen && ( )}
{!historyOpen && ( Notebooks / )} {editingTitle ? (
{ e.preventDefault(); await notebooksApi.update(nb.id, { title }); setEditingTitle(false); load(); }} > setTitle(e.target.value)} onBlur={async () => { setEditingTitle(false); if (title.trim() && title !== nb.title) { await notebooksApi.update(nb.id, { title }); load(); } else { setTitle(nb.title); } }} aria-label="Notebook title" />
) : (

setEditingTitle(true)} title="Click to rename"> {nb.title}

)}
{loadingChatId ? (
) : turns.length === 0 ? (

Ask anything about {nb.documents.length} posts. Answers cite the passage they came from.

{nb.documents.length > 0 && (
{STARTERS.map((s) => ( ))}
)}
) : null} {!loadingChatId && turns.map((t, i) => (
{t.steps && t.steps.length > 0 && (
{t.status ? `${t.status}...` : `${t.steps.length} steps through the archive`}
    {t.steps.map((s, j) => (
  1. {s}
  2. ))}
)} {t.status && !t.steps?.length &&

{t.status}...

} {t.text && (t.role === "assistant" ? ( ) : (

{t.text}

))} {t.citations && } {t.role === "assistant" && t.id && t.text && !t.status && } {t.saved && t.saved.length > 0 && (

{t.saved .map((c) => (c.op === "delete" ? `Removed note: ${c.key}` : `Saved note: ${c.key} = ${c.value}`)) .join(" · ")}{" "} (edit in Settings)

)}
))}
{ e.preventDefault(); void ask(q); }} className="inline-form nbv-ask" > setQ(e.target.value)} placeholder={nb.documents.length ? "What have I written about pricing?" : "Add posts to start asking"} aria-label="Ask your notebook" disabled={!nb.documents.length || Boolean(loadingChatId)} />
{adding && ( setAdding(false)} wide> d.id)} /> {addError &&

{addError}

}
)} {reading && setReading(null)} />} {quoted && ( setQuoted(null)}>

lines {quoted.line_start} {quoted.line_end > quoted.line_start ? ` to ${quoted.line_end}` : ""}

{quoted.span}
)}
); }