import React, { useRef, useState, useCallback, useEffect } from "react"; import { Editor as TiptapEditor } from "@tiptap/core"; import { UndoManager } from "yjs"; import type * as Y from "yjs"; import type { HocuspocusProvider } from "@hocuspocus/provider"; import { MessageCircle } from "lucide-react"; import { Tooltip } from "./components/Tooltip"; import { oklchFromHue } from "#shared/theme"; import { Editor } from "./editor/Editor"; import { CommentMarginIcons } from "./components/CommentPopover"; import { CommentDialog } from "./components/CommentDialog"; import { ChatPanel, type ModelOption } from "./components/ChatPanel"; import { TableOfContents } from "./components/TableOfContents"; import { TopBar } from "./components/TopBar"; import { PublishDialog, type PublishState, type PublishStageEvent } from "./components/PublishDialog"; import { MobileTocSidebar } from "./components/MobileTocSidebar"; import { useAgentChat } from "./hooks/useAgentChat"; import { usePublishStatus } from "./hooks/usePublishStatus"; import type { CommentStore } from "./editor/comments"; import type { FrontmatterStore } from "./editor/frontmatter/frontmatter-store"; import type { EmbedStore } from "./editor/embeds/embed-store"; import type { EmbedDataStore } from "./editor/embeds/embed-data-store"; import { FrontmatterHero } from "./editor/frontmatter/FrontmatterHero"; import { SettingsDrawer } from "./editor/frontmatter/SettingsDrawer"; import { EditorFooter } from "./editor/EditorFooter"; import { EmbedStudio } from "./components/EmbedStudio"; import type { UIMessage } from "ai"; import { loadMessages, saveMessages } from "./utils/chat-persistence"; import { stableFallbackUser, colorFromName, type CollabUser } from "./utils/user"; // Re-exported for any downstream code that still imports from App.tsx export type { CollabUser } from "./utils/user"; export default function App() { // --- Identity & auth --------------------------------------------------- const [user, setUser] = useState(stableFallbackUser); const [loginUrl, setLoginUrl] = useState(null); const [isAuthenticated, setIsAuthenticated] = useState(true); // Defaults to true so the UI is permissive until /api/auth/status answers. // When OAuth is disabled (local dev), the backend always returns canEdit=true. const [canEdit, setCanEdit] = useState(true); const [chatUserId, setChatUserId] = useState(() => user.name); useEffect(() => { fetch("/api/auth/status", { credentials: "include" }) .then((r) => r.json()) .then((data) => { setIsAuthenticated(data.authenticated); setCanEdit(Boolean(data.canEdit)); if (data.authenticated && data.user) { const name = data.user.fullName || data.user.name; setUser({ name, color: colorFromName(data.user.name), avatarUrl: data.user.avatarUrl || undefined, }); setChatUserId(data.user.name); setLoginUrl(null); } else if (data.loginUrl) { setLoginUrl(data.loginUrl); } }) .catch(() => {}); }, []); // --- Document / editor refs ------------------------------------------- const docName = "default"; const editorRef = useRef(null); const editorContainerRef = useRef(null); const providerRef = useRef(null); const publishDialogRef = useRef(null); const [editorInstance, setEditorInstance] = useState(null); const [containerEl, setContainerEl] = useState(null); const [commentStore, setCommentStore] = useState(null); const [frontmatterStore, setFrontmatterStore] = useState(null); const [embedStore, setEmbedStore] = useState(null); const [embedDataStore, setEmbedDataStore] = useState(null); const [settingsMap, setSettingsMap] = useState | null>(null); const [undoManager, setUndoManager] = useState(null); const [isEditorReady, setIsEditorReady] = useState(false); // --- UI state --------------------------------------------------------- const [commentDialogOpen, setCommentDialogOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [chatOpen, setChatOpen] = useState(false); const [embedStudioSrc, setEmbedStudioSrc] = useState(null); // Stable session id for the currently-open EmbedStudio instance. // Used as the React key so the component does NOT remount when the // agent renames the chart file mid-session (rename flips // `embedStudioSrc`, but the same studio instance keeps its chat // state). const [embedStudioSession, setEmbedStudioSession] = useState(null); const [tocSidebarOpen, setTocSidebarOpen] = useState(false); const [tocAutoCollapse, setTocAutoCollapse] = useState(false); const [publishState, setPublishState] = useState("idle"); const [publishError, setPublishError] = useState(""); const [publishStage, setPublishStage] = useState(null); const [yDoc, setYDoc] = useState(null); const selectionRange = useRef<{ from: number; to: number } | null>(null); const publishStatus = usePublishStatus(yDoc); // --- Theme ------------------------------------------------------------ const [theme, setTheme] = useState<"light" | "dark">(() => { return (document.documentElement.getAttribute("data-theme") as "light" | "dark") || "dark"; }); useEffect(() => { const saved = localStorage.getItem("theme") as "light" | "dark" | null; if (saved) { document.documentElement.setAttribute("data-theme", saved); setTheme(saved); } }, []); const toggleTheme = useCallback(() => { const next = theme === "dark" ? "light" : "dark"; document.documentElement.setAttribute("data-theme", next); localStorage.setItem("theme", next); setTheme(next); }, [theme]); // --- Model list ------------------------------------------------------- const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState(""); const modelRef = useRef(""); useEffect(() => { fetch("/api/models") .then((r) => r.json()) .then((data: ModelOption[]) => { setModels(data); if (data.length > 0 && !modelRef.current) { setSelectedModel(data[0].id); modelRef.current = data[0].id; } }) .catch(() => {}); }, []); const handleModelChange = useCallback((id: string) => { setSelectedModel(id); modelRef.current = id; }, []); // --- Effects tied to stores ------------------------------------------- // Sync TOC auto-collapse flag from frontmatter useEffect(() => { if (!frontmatterStore) return; const sync = () => { setTocAutoCollapse(frontmatterStore.get("tableOfContentsAutoCollapse") as boolean); }; sync(); return frontmatterStore.observe(sync); }, [frontmatterStore]); // Fallback: dismiss spinner after 5s even if sync hasn't fired useEffect(() => { if (isEditorReady) return; const id = setTimeout(() => setIsEditorReady(true), 5000); return () => clearTimeout(id); }, [isEditorReady]); // Sync primary hue from Yjs settings to CSS variables useEffect(() => { if (!settingsMap) return; const sync = () => { const h = settingsMap.get("primaryHue") as number | undefined; if (h !== undefined) { // Only override --primary-base: _variables.css derives --primary-color // and --primary-color-hover from it via CSS relative-color syntax. document.documentElement.style.setProperty("--primary-base", oklchFromHue(h)); } else { // When the Yjs settings map has no primaryHue (fresh state or after // reset-article), drop the inline override so the CSS default from // _variables.css wins. Otherwise a stale hue from a previous session // survives the reset visually, and the demo has to re-paint it at // boot time (which looks like an unmotivated color jump). document.documentElement.style.removeProperty("--primary-base"); } }; sync(); settingsMap.observe(sync); return () => settingsMap.unobserve(sync); }, [settingsMap]); // Listen for embed studio open events from HtmlEmbedView useEffect(() => { const handler = (e: Event) => { const src = (e as CustomEvent).detail?.src; if (src) { setEmbedStudioSrc(src); setEmbedStudioSession(`es-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`); } }; window.addEventListener("open-embed-studio", handler); return () => window.removeEventListener("open-embed-studio", handler); }, []); // Rename every htmlEmbed node referencing `oldSrc` to `newSrc` in the // ProseMirror doc, then lift the new src into state so the // EmbedStudio props and the document stay consistent. The agent // triggers this from createEmbed({ filename }) inside useEmbedChat. const handleEmbedRename = useCallback( (oldSrc: string, newSrc: string) => { const editor = editorRef.current; if (editor) { const { state } = editor; const tr = state.tr; let changed = false; state.doc.descendants((node, pos) => { if (node.type.name === "htmlEmbed" && node.attrs.src === oldSrc) { tr.setNodeMarkup(pos, undefined, { ...node.attrs, src: newSrc }); changed = true; } }); if (changed) editor.view.dispatch(tr); } setEmbedStudioSrc((prev) => (prev === oldSrc ? newSrc : prev)); }, [], ); // Global entry point for the Embed Studio (TopBar button). Smart // behaviour: if charts already exist, open the studio on one of them // (browse/edit mode via the FilesSidebar) without touching the // document. Only when there is no chart at all do we fall back to // creating a fresh one - mirroring the slash menu's "New Chart" flow. // Both paths route through the existing `open-embed-studio` listener // so session/key handling stays in one place. const handleOpenEmbedStudio = useCallback(() => { const keys = embedStore ? embedStore.keys() : []; if (keys.length > 0) { // Prefer a real chart over the always-present banner so the // studio lands on content the user most likely wants to tweak. const preferred = keys.find((k) => k !== "banner.html") ?? keys[0]; window.dispatchEvent( new CustomEvent("open-embed-studio", { detail: { src: preferred } }), ); return; } const editor = editorRef.current; if (!editor) return; const id = `d3-chart-${Date.now().toString(36)}`; const src = `${id}.html`; (editor.chain().focus() as any).insertHtmlEmbed().run(); setTimeout(() => { const { doc } = editor.state; let targetPos = -1; doc.descendants((node, pos) => { if (node.type.name === "htmlEmbed" && !node.attrs.src) { targetPos = pos; return false; } }); if (targetPos >= 0) { editor.view.dispatch( editor.state.tr.setNodeMarkup(targetPos, undefined, { ...editor.state.doc.nodeAt(targetPos)?.attrs, src, title: "New chart", }), ); } window.dispatchEvent( new CustomEvent("open-embed-studio", { detail: { src } }), ); }, 50); }, [embedStore]); // When the user opens the chat with an active selection, broadcast it // via Yjs awareness so every collaborator (including us) sees a // persistent highlight "my agent is working on this range". The // AgentFocus extension handles CRDT-safe position tracking; we just // toggle it on/off based on chat state. Safe w.r.t. undo: the // extension only writes to awareness, it never emits a Yjs-tracked // transaction. // // We listen to `selectionUpdate` while the chat is open so that: // - if the selection was not yet readable at chat-open time // (focus/blur race when clicking the FAB), the first selection // update still syncs the highlight; // - if the user selects new text while the chat is open, the // highlight follows the new range. // On collapsed selection we leave the previously broadcast focus in // place - the user may have clicked in the chat textarea without // wanting to lose the agent's current target. useEffect(() => { if (!editorInstance) return; if (!chatOpen) { editorInstance.commands.clearAgentFocus(); return; } const syncAgentFocus = () => { const { from, to } = editorInstance.state.selection; if (from === to) return; editorInstance.commands.setAgentFocus({ from, to }); }; syncAgentFocus(); editorInstance.on("selectionUpdate", syncAgentFocus); return () => { editorInstance.off("selectionUpdate", syncAgentFocus); }; }, [chatOpen, editorInstance]); // --- Chat / agent ----------------------------------------------------- const chatUserIdRef = useRef(chatUserId); chatUserIdRef.current = chatUserId; const handleArticleChatChange = useCallback((msgs: UIMessage[]) => { saveMessages(chatUserIdRef.current, "article", msgs); }, []); const agentChat = useAgentChat({ editor: editorInstance, undoManager, frontmatterStore, modelRef, onMessagesChange: handleArticleChatChange, }); // Restore persisted messages after mount and when user identity changes const agentSetMessagesRef = useRef(agentChat.setMessages); agentSetMessagesRef.current = agentChat.setMessages; const chatUserIdLoadedRef = useRef(null); useEffect(() => { if (chatUserIdLoadedRef.current === chatUserId) return; chatUserIdLoadedRef.current = chatUserId; const stored = loadMessages(chatUserId, "article"); if (stored && stored.length > 0) { agentSetMessagesRef.current(stored); } }, [chatUserId]); // Dev-only hook: let the demo recording script script-drive the main // chat panel. The Playwright showcase script dispatches `__demo-chat` to: // - open the floating chat panel, // - inject a fake user prompt + assistant reply, // - optionally rewrite a paragraph in the editor body, // all without waiting for a real LLM round-trip. useEffect(() => { if (!import.meta.env.DEV) return; const handler = (event: Event) => { const detail = (event as CustomEvent).detail as | { open?: boolean; messages?: UIMessage[]; replace?: { from: string; to: string; /** * Explicit PM positions to replace. When provided we use * them as-is and skip the (fragile) string search below. * Required for paragraphs that contain non-text leaf * nodes (inline math, mentions, images...) because their * textContent does NOT match the `from` plain-string. */ range?: { from: number; to: number }; /** * "none" -> atomic replace in one transaction. The * viewer sees the new phrase "snap in", * unambiguously an AI edit. * "typewriter" -> stream chars at human cadence. Looks * like the owner is typing (which is * NOT what we want for an agent action - * use "none" for rephrase actions so * they don't look like user input). * Default: "typewriter" for backwards compatibility. */ animation?: "none" | "typewriter"; /** * Keep an AgentFocus decoration on the replacement * range for `lingerAgentFocusMs` ms after the swap so * the viewer sees the " agent" label hover over * the new sentence - visual proof the agent owned that * edit. Ignored when 0 / undefined (caller will manage * focus clearing itself). */ lingerAgentFocusMs?: number; }; /** * Apply a formatting mark (bold/italic/strike/code) to an * explicit PM range. Used by the demo to showcase the * agent calling a simple "make this bold" tool - the same * path a real agent's `toggleMark` tool call would take. */ format?: { mark: "bold" | "italic" | "strike" | "code"; range: { from: number; to: number }; }; /** * Wrap a PM range in a Link mark. Same pipeline as `format` * (explicit PM range, go through Tiptap's chain) so the * new mark lands collaboratively via Yjs and participates * in the normal undo stack. */ link?: { url: string; range: { from: number; to: number }; }; } | undefined; if (typeof detail?.open === "boolean") setChatOpen(detail.open); if (Array.isArray(detail?.messages)) { agentSetMessagesRef.current(detail.messages); } if (detail?.replace && editorRef.current) { const editor = editorRef.current; const target = detail.replace.from; const replacement = detail.replace.to; const explicitRange = detail.replace.range; const animation = detail.replace.animation ?? "typewriter"; const lingerAgentFocusMs = detail.replace.lingerAgentFocusMs ?? 0; // Resolve the target range AT DISPATCH TIME, not here. We used // to do the string search on event receipt, cache from/to, // then setTimeout(520) into `agentRewriteRange`. Problem: the // assistant reply was still streaming AND remote peers kept // typing during those 520ms. Any insert before the cached // range would silently shift Alice's target by N chars, and // the typewriter would then eat Bob's sentence or the wrong // half of Alice's own paragraph. Running the lookup inside // the setTimeout reads the LIVE doc at the actual kickoff // moment, so concurrent edits can't invalidate it. window.setTimeout(() => { const ed = editorRef.current; if (!ed) return; let from = -1; let to = -1; // Preferred path: explicit PM range from the caller. Must // still be clamped against the LIVE doc size in case remote // edits shrank the doc since the event fired. if (explicitRange) { const size = ed.state.doc.content.size; const a = Math.max(0, Math.min(explicitRange.from, size)); const b = Math.max(0, Math.min(explicitRange.to, size)); if (b > a) { from = a; to = b; } else { console.error( "[__demo-chat] replace.range is empty or inverted:", explicitRange, ); } } // Fallback: walk the LIVE doc and find the first textblock // whose content contains `target`. Because this runs at // dispatch time (not at event-receipt time) no remote edit // can race the result. if (from === -1) { ed.state.doc.descendants((node, pos) => { if (from !== -1) return false; if (node.isTextblock) { const text = node.textBetween( 0, node.content.size, "\n", "\n", ); const idx = text.indexOf(target); if (idx !== -1) { from = pos + 1 + idx; to = from + target.length; return false; } } return true; }); } if (from === -1 || to === -1) { console.error( "[__demo-chat] replace target not found:", target.slice(0, 60) + (target.length > 60 ? "..." : ""), "- pass `replace.range` to target paragraphs with inline atoms", ); return; } // Opportunistic scroll: AgentRewrite maps its own cursor // through every subsequent tr, so this DOM lookup only // needs to be approximately right. try { const domAt = ed.view.domAtPos(from); let el: HTMLElement | null = domAt.node instanceof HTMLElement ? domAt.node : domAt.node.parentElement; while ( el && el.nodeName !== "P" && !/^H[1-6]$/.test(el.nodeName) ) { el = el.parentElement; } if (el && typeof el.scrollIntoView === "function") { el.scrollIntoView({ behavior: "smooth", block: "center" }); } } catch { /* non-fatal */ } ed.commands.agentRewriteRange({ from, to, text: replacement, animation, }); // Re-seed the AgentFocus on the REPLACEMENT range after the // swap so the " agent" label keeps hovering over the // new sentence - otherwise the decoration collapses during // the delete-then-insert, and viewers perceive the new // phrase as user typing instead of an agent edit. For the // atomic "none" animation the new text is present // immediately; for the typewriter we wait long enough for // the first chunk to land so PM positions are stable. if (lingerAgentFocusMs > 0) { const seedDelay = animation === "none" ? 0 : 80; window.setTimeout(() => { const live = editorRef.current; if (!live) return; const newFrom = from; const newTo = from + replacement.length; const size = live.state.doc.content.size; const a = Math.max(0, Math.min(newFrom, size)); const b = Math.max(0, Math.min(newTo, size)); if (b > a) { live.commands.setAgentFocus({ from: a, to: b }); } window.setTimeout(() => { const again = editorRef.current; again?.commands.clearAgentFocus(); }, lingerAgentFocusMs); }, seedDelay); } }, 520); } // ---- Format action: bold / italic / strike / code ---------------- // The agent's simplest possible tool call: "apply mark M to // range [from, to]". We scroll the target into view and route // through Tiptap's own `setMark` commands so the operation goes // through the real editor pipeline (Yjs syncs, undo step, mark // extension hooks) - same path a production agent tool would // take. No custom animation: the viewer sees the text turn // bold in a single frame, which reads as an instant agent edit. if (detail?.format && editorRef.current) { const editor = editorRef.current; const size = editor.state.doc.content.size; const from = Math.max(0, Math.min(detail.format.range.from, size)); const to = Math.max(0, Math.min(detail.format.range.to, size)); if (to <= from) { console.error( "[__demo-chat] format range empty or inverted:", detail.format.range, ); return; } try { const domAt = editor.view.domAtPos(from); let el: HTMLElement | null = domAt.node instanceof HTMLElement ? domAt.node : domAt.node.parentElement; while (el && el.nodeName !== "P" && !/^H[1-6]$/.test(el.nodeName)) { el = el.parentElement; } if (el && typeof el.scrollIntoView === "function") { el.scrollIntoView({ behavior: "smooth", block: "center" }); } } catch { /* non-fatal */ } const chain = editor .chain() .focus() .setTextSelection({ from, to }); switch (detail.format.mark) { case "bold": chain.setBold().run(); break; case "italic": chain.setItalic().run(); break; case "strike": chain.setStrike().run(); break; case "code": chain.setCode().run(); break; default: console.error( "[__demo-chat] unknown format mark:", detail.format.mark, ); } } // ---- Link action: wrap a PM range in a Link mark ------------------ // Goes through Tiptap's `setLink` command (same path the bubble // toolbar uses) so the mark syncs over Yjs, respects safe-URL // validation, and lands in the undo stack. Scrolls the target // into view before applying so the viewer sees the link underline // appear on the actual phrase. // // Drift-proof resolution // ---------------------- // The caller MAY pass an explicit PM `range`, but doc positions // are fragile under concurrent edits (another peer inserting // content earlier in the doc will shift every subsequent // position). So we prefer the substring-based path when the // caller provides `paragraphSnippet` + `substring`: walk the // LIVE doc at dispatch time, find the matching text node, and // resolve fresh PM positions. The explicit range only wins if // the text at [from..to] still matches `substring`. Otherwise // we fall back to the search, which is always correct as long // as the target text hasn't been edited away. const detailLink = (detail as unknown as { link?: { url: string; range?: { from: number; to: number }; paragraphSnippet?: string; substring?: string; }; }).link; if (detailLink && editorRef.current) { const editor = editorRef.current; const size = editor.state.doc.content.size; const url = (detailLink.url || "").trim(); if (!url) { console.error("[__demo-chat] link URL missing:", detailLink); return; } const resolveBySubstring = (): { from: number; to: number } | null => { if (!detailLink.paragraphSnippet || !detailLink.substring) return null; let found: { from: number; to: number } | null = null; editor.state.doc.descendants((node, pos) => { if (found) return false; if (!node.isTextblock) return true; const text = node.textBetween(0, node.content.size, "\n", "\n"); if (!text.includes(detailLink.paragraphSnippet!)) return true; const idx = text.indexOf(detailLink.substring!); if (idx === -1) return false; found = { from: pos + 1 + idx, to: pos + 1 + idx + detailLink.substring!.length, }; return false; }); return found; }; let from = -1; let to = -1; if (detailLink.range) { const tentativeFrom = Math.max(0, Math.min(detailLink.range.from, size)); const tentativeTo = Math.max(0, Math.min(detailLink.range.to, size)); if (tentativeTo > tentativeFrom) { const textAtRange = editor.state.doc.textBetween( tentativeFrom, tentativeTo, ); const rangeStillMatches = !detailLink.substring || textAtRange === detailLink.substring; if (rangeStillMatches) { from = tentativeFrom; to = tentativeTo; } } } if (from === -1) { const resolved = resolveBySubstring(); if (resolved) { from = resolved.from; to = resolved.to; } } if (from === -1 || to <= from) { console.error( "[__demo-chat] link target not resolvable:", detailLink, ); return; } try { const domAt = editor.view.domAtPos(from); let el: HTMLElement | null = domAt.node instanceof HTMLElement ? domAt.node : domAt.node.parentElement; while (el && el.nodeName !== "P" && !/^H[1-6]$/.test(el.nodeName)) { el = el.parentElement; } if (el && typeof el.scrollIntoView === "function") { el.scrollIntoView({ behavior: "smooth", block: "center" }); } } catch { /* non-fatal */ } editor .chain() .focus() .setTextSelection({ from, to }) .setLink({ href: url }) .run(); } // ---- Citation action: seed an entry in the shared citations map // then insert an inline citation node at a PM position, and make // sure a block exists at the end of the doc. // // Same pipeline as the real CitationPanel (see CitationPanel.tsx): // write a CSL-JSON entry into `citationsMap` with a key, dispatch // `insertCitation(key)` on the editor, then append a bibliography // section if the doc doesn't have one yet. This exercises the // production code path end to end - chip auto-labelling included. const detailCitation = (detail as unknown as { citation?: { key: string; entry: unknown; at?: number; paragraphSnippet?: string; anchor?: "end" | "start"; }; }).citation; if (detailCitation && editorRef.current) { const editor = editorRef.current; const size = editor.state.doc.content.size; // Drift-proof anchor resolution: if `paragraphSnippet` is // provided, walk the LIVE doc for the matching paragraph and // return its start or end position. Otherwise fall back to the // explicit `at` (which can be stale under concurrent upstream // edits). Same philosophy as the link resolver above. const resolveByParagraph = (): number | null => { if (!detailCitation.paragraphSnippet) return null; const anchor = detailCitation.anchor ?? "end"; let found: number | null = null; editor.state.doc.descendants((node, pos) => { if (found !== null) return false; if (!node.isTextblock) return true; const text = node.textBetween(0, node.content.size, "\n", "\n"); if (!text.includes(detailCitation.paragraphSnippet!)) return true; found = anchor === "end" ? pos + node.nodeSize - 1 : pos + 1; return false; }); return found; }; let at = -1; const resolved = resolveByParagraph(); if (resolved !== null) { at = resolved; } else if (typeof detailCitation.at === "number") { at = Math.max(0, Math.min(detailCitation.at, size)); } if (at < 0) { console.error( "[__demo-chat] citation target not resolvable:", detailCitation, ); return; } const citationsMap = (editor.storage as unknown as Record) .citation as { citationsMap?: { set: (k: string, v: unknown) => void } } | undefined; if (!citationsMap?.citationsMap) { console.error( "[__demo-chat] citationsMap not attached to editor storage", ); return; } citationsMap.citationsMap.set(detailCitation.key, detailCitation.entry); try { const domAt = editor.view.domAtPos(at); let el: HTMLElement | null = domAt.node instanceof HTMLElement ? domAt.node : domAt.node.parentElement; while (el && el.nodeName !== "P" && !/^H[1-6]$/.test(el.nodeName)) { el = el.parentElement; } if (el && typeof el.scrollIntoView === "function") { el.scrollIntoView({ behavior: "smooth", block: "center" }); } } catch { /* non-fatal */ } editor .chain() .focus() .setTextSelection(at) .insertCitation(detailCitation.key) .run(); let hasBibliography = false; editor.state.doc.descendants((node) => { if (node.type.name === "bibliography") hasBibliography = true; }); if (!hasBibliography) { const endPos = editor.state.doc.content.size; editor .chain() .insertContentAt(endPos, [ { type: "paragraph" }, { type: "bibliography" }, ]) .run(); } } }; window.addEventListener("__demo-chat", handler); return () => window.removeEventListener("__demo-chat", handler); }, []); // Dev-only: imperative settings mutation for the demo (primary hue). // Writing straight to the shared Yjs `settings` map gives every peer // the updated colour via the normal observer path - exactly what // moving the HueSlider does, minus the UI detour. useEffect(() => { if (!import.meta.env.DEV) return; if (!settingsMap) return; const handler = (event: Event) => { const detail = (event as CustomEvent).detail as | { hue?: number } | undefined; if (typeof detail?.hue === "number") { const clamped = Math.max(0, Math.min(360, Math.round(detail.hue))); settingsMap.set("primaryHue", clamped); } }; window.addEventListener("__demo-settings", handler); return () => window.removeEventListener("__demo-settings", handler); }, [settingsMap]); // Dev-only: seed or clear data files in the EmbedDataStore. Lets the // showcase demo populate the FilesSidebar with a realistic CSV before the // agent "reads" it, without requiring a real drag-and-drop upload. useEffect(() => { if (!import.meta.env.DEV) return; if (!embedDataStore) return; const handler = (event: Event) => { const detail = (event as CustomEvent).detail as | { clear?: boolean; file?: { name: string; ext?: string; content: string; columns?: string[]; rowCount?: number; uploader?: string; }; } | undefined; if (!detail) return; if (detail.clear) { for (const key of embedDataStore.keys()) embedDataStore.remove(key); return; } if (detail.file) { const f = detail.file; const ext = (f.ext ?? f.name.split(".").pop() ?? "txt").toLowerCase(); embedDataStore.set({ meta: { name: f.name, ext, size: new Blob([f.content]).size, uploader: f.uploader ?? "demo", addedAt: Date.now(), rowCount: f.rowCount, columns: f.columns, }, content: f.content, }); } }; window.addEventListener("__demo-embed-data", handler); return () => window.removeEventListener("__demo-embed-data", handler); }, [embedDataStore]); // Dev-only: write arbitrary embed HTML (for charts beyond the banner) // and optionally rename an existing src so the demo can showcase the // agent-picked filename live. Mirrors what the real createEmbed tool // does via useEmbedChat when the agent provides a `filename`. useEffect(() => { if (!import.meta.env.DEV) return; if (!embedStore) return; const handler = (event: Event) => { const detail = (event as CustomEvent).detail as | { src?: string; html?: string; renameFrom?: string } | undefined; if (!detail?.src || typeof detail.html !== "string") return; const { src, html, renameFrom } = detail; if (renameFrom && renameFrom !== src) { embedStore.set(src, html); embedStore.remove(renameFrom); handleEmbedRename(renameFrom, src); } else { embedStore.set(src, html); } }; window.addEventListener("__demo-embed-set", handler); return () => window.removeEventListener("__demo-embed-set", handler); }, [embedStore, handleEmbedRename]); // --- Editor lifecycle callbacks --------------------------------------- const editorContainerCallback = useCallback((node: HTMLDivElement | null) => { editorContainerRef.current = node; setContainerEl(node); }, []); const onEditorReady = useCallback((editor: TiptapEditor | null) => { editorRef.current = editor; setEditorInstance(editor); // Dev-only: expose the Tiptap editor for the demo script so it can // drive PM selection natively (which the collaboration-cursor // extension then broadcasts via Yjs awareness so Bob and Carol see // Alice's selected range in her persona color). if (import.meta.env.DEV) { (window as unknown as { __demoEditor?: TiptapEditor | null }).__demoEditor = editor; } }, []); const onProviderReady = useCallback((provider: HocuspocusProvider) => { providerRef.current = provider; setYDoc(provider.document); provider.on("synced", () => setIsEditorReady(true)); }, []); const onCommentStoreReady = useCallback((store: CommentStore) => setCommentStore(store), []); const onFrontmatterStoreReady = useCallback((store: FrontmatterStore) => setFrontmatterStore(store), []); const onEmbedStoreReady = useCallback((store: EmbedStore) => setEmbedStore(store), []); const onEmbedDataStoreReady = useCallback( (store: EmbedDataStore) => setEmbedDataStore(store), [], ); const onSettingsMapReady = useCallback((map: Y.Map) => setSettingsMap(map), []); // --- Publish flow ----------------------------------------------------- const publishEventSourceRef = useRef(null); const openPublishDialog = useCallback(() => { setPublishState("idle"); setPublishError(""); setPublishStage(null); publishDialogRef.current?.showModal(); }, []); const closePublishDialog = useCallback(() => { publishDialogRef.current?.close(); }, []); useEffect(() => { return () => { publishEventSourceRef.current?.close(); publishEventSourceRef.current = null; }; }, []); // Dev-only: demo hook that opens the Publish dialog without hitting // the real server. The viewer sees the modal slide in at the very // end of the scenario so the final frame is "hero agent ready to // ship the article" - without actually kicking off a publish job. useEffect(() => { if (!import.meta.env.DEV) return; const handler = (event: Event) => { const detail = (event as CustomEvent).detail as | { open?: boolean } | undefined; if (detail?.open) { openPublishDialog(); } else if (detail?.open === false) { publishDialogRef.current?.close(); } }; window.addEventListener("__demo-publish", handler); return () => window.removeEventListener("__demo-publish", handler); }, [openPublishDialog]); const handlePublish = useCallback(async () => { setPublishState("loading"); setPublishError(""); setPublishStage(null); try { const res = await fetch("/api/publish", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ docName }), }); if (res.status === 409) { const data = await res.json().catch(() => ({ error: "Publish already in progress" })); throw new Error( data.userName ? `${data.userName} is already publishing. Please wait.` : "A publish is already in progress. Please wait." ); } if (!res.ok) { const data = await res.json().catch(() => ({ error: "Publish failed" })); throw new Error(data.error || "Publish failed"); } const { jobId } = (await res.json()) as { jobId: string }; if (!jobId) throw new Error("No job id returned"); // Open the SSE stream and forward stage events to the dialog. const es = new EventSource(`/api/publish/stream?jobId=${encodeURIComponent(jobId)}`, { withCredentials: true, }); publishEventSourceRef.current = es; es.addEventListener("stage", (e: MessageEvent) => { try { const payload = JSON.parse(e.data) as PublishStageEvent; setPublishStage(payload); } catch { // ignore malformed payloads } }); es.addEventListener("done", (e: MessageEvent) => { try { const payload = JSON.parse(e.data) as { success: boolean; error?: string }; if (payload.success) { setPublishState("success"); } else { setPublishState("error"); setPublishError(payload.error || "Publish failed"); } } catch { setPublishState("error"); setPublishError("Malformed server event"); } finally { es.close(); publishEventSourceRef.current = null; } }); es.onerror = () => { // Network blip or server closed the stream unexpectedly. If we haven't // already reached a terminal state, mark the publish as errored. if (es.readyState === EventSource.CLOSED) { publishEventSourceRef.current = null; setPublishState((prev) => (prev === "loading" ? "error" : prev)); setPublishError((prev) => prev || "Connection to publish stream lost"); } }; } catch (err: any) { setPublishState("error"); setPublishError(err.message || "Unknown error"); } }, [docName]); // --- Comment flow ----------------------------------------------------- const handleAddComment = useCallback(() => { const editor = editorRef.current; if (!editor) return; const { from, to } = editor.state.selection; if (from === to) return; selectionRange.current = { from, to }; setCommentDialogOpen(true); }, []); const handleCommentSubmit = useCallback((text: string) => { const editor = editorRef.current; if (!editor || !commentStore || !selectionRange.current) return; const { from, to } = selectionRange.current; const id = `c_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; commentStore.add({ id, author: user.name, authorColor: user.color, text, createdAt: Date.now(), resolved: false, }); editor .chain() .focus() .setTextSelection({ from, to }) .setComment(id) .run(); selectionRange.current = null; }, [commentStore, user]); // --- Render ----------------------------------------------------------- return ( ); }