import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; import { Link, useParams, useSearchParams } from "react-router-dom"; import { artifactsApi, notebooksApi } 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 { FlashcardsIcon, HeadphonesIcon, InfographicIcon, QuizIcon, ReportIcon, SlidesIcon, TelescopeIcon, VideoIcon } from "../components/icons/Icons"; import { MindMapDialog, MindMapExplorer, MindMapRow } from "../components/MindMap"; import { FlashcardsDialog, type FlashcardsRequest } from "../components/Flashcards"; import { InfographicDialog, type InfographicRequest } from "../components/Infographic"; import { SlideDeckDialog, SlideDeckRow, type SlideDeckRequest } from "../components/SlideDeck"; import { AudioOverviewCard, AudioOverviewDialog, prefetchVoices, type AudioOverviewRequest } from "../components/AudioOverview"; import { QuizDialog, type QuizRequest } from "../components/Quiz"; import { prefetchFocus } from "../components/SourceFocusFields"; import { prefetchSuggestions } from "../hooks/reportSuggestions"; import { useArtifactLimits } from "../hooks/useArtifactLimits"; import { ReportDialog, type ReportRequest } from "../components/ReportDialog"; import { PaneResizer, usePaneWidths } from "../components/PaneResizer"; import { Reader } from "../components/Reader"; import { seedDocs } from "../components/video/sourceCache"; import { ConfirmButton, errorMessage, formatDate, Loading, Modal } from "../components/ui"; import { VideoCreateForm } from "./VideoCreate"; import { canPost, PostButton } from "../components/launchpad/PostButton"; 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[]; }; /** Chats under date headings: Today, Yesterday, the last week, the last month, then by month. Newest first. */ function groupChats(chats: ChatSummary[]): { label: string; items: ChatSummary[] }[] { const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); const today = startOfDay(new Date()); const groups: { label: string; items: ChatSummary[] }[] = []; const stamp = (c: ChatSummary) => (c.updated_at ? new Date(c.updated_at) : new Date()); // a chat with no time yet is brand new [...chats].sort((a, b) => +stamp(b) - +stamp(a)).forEach((c) => { const when = stamp(c); const days = Math.round((today - startOfDay(when)) / 86400000); const label = days <= 0 ? "Today" : days === 1 ? "Yesterday" : days <= 7 ? "Previous 7 days" : days <= 30 ? "Previous 30 days" : when.toLocaleDateString(undefined, { month: "long", year: "numeric" }); const last = groups[groups.length - 1]; if (last && last.label === label) last.items.push(c); else groups.push({ label, items: [c] }); }); return groups; } 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; } } /** Create: one click per format with sensible defaults, options folded away; then what was made from this notebook. */ function StudioPanel({ notebookId, notebookTitle, docs, chats, currentChatId, disabled, quizOpen, onQuizClose, onCreateQuiz, cardsOpen, onCardsClose, onCreateCards, infographicOpen, onInfographicClose, onCreateInfographic, audioOpen, onAudioClose, onCreateAudio, slidesOpen, onSlidesClose, onCreateSlides, reportOpen, onReportClose, onCreateReport, onCreateVideo }: { notebookId: string; notebookTitle: string; docs: { id: string; title: string; locked?: boolean }[]; chats: ChatSummary[]; currentChatId: string | null; disabled: boolean; quizOpen: boolean; // the quiz settings are open (from here, or from the chat's own Quiz button) onQuizClose: () => void; onCreateQuiz: () => void; cardsOpen: boolean; onCardsClose: () => void; onCreateCards: () => void; infographicOpen: boolean; onInfographicClose: () => void; onCreateInfographic: () => void; audioOpen: boolean; onAudioClose: () => void; onCreateAudio: () => void; slidesOpen: boolean; onSlidesClose: () => void; onCreateSlides: () => void; reportOpen: boolean; onReportClose: () => void; onCreateReport: () => void; onCreateVideo: () => void; }) { const [artifacts, setArtifacts] = useState(null); const [error, setError] = useState(null); const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null); const [mapDialog, setMapDialog] = useState(false); 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]); // The voices for the audio overview settings take a few seconds to list: ask now so they are ready when it opens. useEffect(() => { prefetchVoices().catch(() => undefined); }, []); // The focus suggestions the study dialogs show, made now so they are waiting when a dialog opens. const limits = useArtifactLimits(); const startIds = docs.filter((d) => !d.locked).slice(0, limits.posts).map((d) => d.id).join(","); useEffect(() => { if (startIds) { prefetchFocus(notebookId, startIds.split(",")); prefetchSuggestions(notebookId, startIds.split(",")); // the Create report dialog's suggested templates, ready before it opens } }, [notebookId, startIds]); // Every "make" from a dialog works the same way: the dialog closes at once, a card in the panel says it is starting, the // artifact replaces it as soon as the server has it (and shows its own progress), and a failure is shown in the panel. const [starting, setStarting] = useState([]); const launch = async (label: string, close: () => void, request: () => Promise, after?: (a: Artifact) => void) => { setError(null); close(); setStarting((l) => [...l, label]); try { const a = await request(); setArtifacts((list) => [a, ...(list ?? [])]); after?.(a); } catch (e) { setError(errorMessage(e)); // 402 also opens the upgrade popup (api/client.ts) } finally { setStarting((l) => { const i = l.indexOf(label); return i < 0 ? l : [...l.slice(0, i), ...l.slice(i + 1)]; }); } }; const makeMap = (documentIds: string[], focus: string) => launch("Mind Constellation", () => setMapDialog(false), () => artifactsApi.generate({ type: "mind_map", notebook_id: notebookId, document_ids: documentIds.length === docs.length ? undefined : documentIds, focus: focus || undefined, }), (a) => setWaitingMap(a.id)); const makeQuiz = (body: QuizRequest) => launch("quiz", onQuizClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "quiz" })); const makeReport = (body: ReportRequest) => launch("report", onReportClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "report" })); const makeInfographic = (body: InfographicRequest) => launch("infographic", onInfographicClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "infographic" })); const makeAudio = (body: AudioOverviewRequest) => launch("audio overview", onAudioClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "audio_overview" })); const makeSlides = (body: SlideDeckRequest) => launch("slide deck", onSlidesClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "slide_deck" })); const makeCards = (body: FlashcardsRequest) => launch("flashcards", onCardsClose, () => artifactsApi.generate({ notebook_id: notebookId, ...body, type: "flashcards" })); 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; return (
Create
{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))} /> ))}
)} {(!others || others.length > 0 || starting.length > 0) && Made from this notebook} {(!others || others.length > 0 || starting.length > 0) &&
{!artifacts && } {starting.map((label, i) => (
))} {others?.map((a) => a.type === "slide_deck" ? ( setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))} /> ) : a.type === "audio_overview" ? ( setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))} /> ) : ( setArtifacts((list) => (list ?? []).filter((x) => x.id !== id))} actions={(art) => canPost(art) ? : null} /> ))}
} {reading && setReading(null)} />} {quizOpen && } {reportOpen && } {infographicOpen && } {audioOpen && } {slidesOpen && } {cardsOpen && } {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 [postFilter, setPostFilter] = useState(""); const [postsOpen, setPostsOpen] = useState(true); const [chatsOpen, setChatsOpen] = useState(true); const [closedGroups, setClosedGroups] = useState([]); // date headings folded shut const [chats, setChats] = useState([]); const [busy, setBusy] = useState(false); const [loadingChatId, setLoadingChatId] = useState(null); const [adding, setAdding] = useState(false); const [makingVideo, setMakingVideo] = useState(false); // the video wizard is open in a modal const [makingQuiz, setMakingQuiz] = useState(false); // the quiz settings are open in a modal const [makingInfographic, setMakingInfographic] = useState(false); // the infographic settings are open in a modal const [makingCards, setMakingCards] = useState(false); // the flashcard settings are open in a modal const [makingSlides, setMakingSlides] = useState(false); // the slide deck settings are open in a modal const [makingAudio, setMakingAudio] = useState(false); // the audio overview settings are open in a modal const [makingReport, setMakingReport] = useState(false); // the report dialog is open in a modal 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 [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); const layout = useRef(null); const panes = usePaneWidths(layout, { leftOpen: historyOpen }); // the side panels' dragged widths // 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 openReport = () => { seedDocs(nb.id, nb.documents); setMakingReport(true); }; const openInfographic = () => { seedDocs(nb.id, nb.documents); setMakingInfographic(true); }; const openAudio = () => { seedDocs(nb.id, nb.documents); setMakingAudio(true); }; const openSlides = () => { seedDocs(nb.id, nb.documents); setMakingSlides(true); }; const openCards = () => { seedDocs(nb.id, nb.documents); setMakingCards(true); }; const openQuiz = () => { seedDocs(nb.id, nb.documents); // the posts this page already has: the dialog shows them at once setMakingQuiz(true); }; 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(); setEditingTitle(false); if (title.trim()) setNb({ ...nb, title }); // shown at once, everywhere; the reload confirms it await notebooksApi.update(nb.id, { title }); load(); }} > setTitle(e.target.value)} onBlur={async () => { setEditingTitle(false); if (title.trim() && title !== nb.title) { setNb({ ...nb, title }); // shown at once, everywhere (the dialogs too); the reload confirms it 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)} />
{makingVideo && ( setMakingVideo(false)} wide> )} {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}
)}
); }