tfrere's picture
tfrere HF Staff
feat(editor): Embed Studio button in the top bar
2dfb335
Raw
History Blame Contribute Delete
49.7 kB
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<CollabUser>(stableFallbackUser);
const [loginUrl, setLoginUrl] = useState<string | null>(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<TiptapEditor | null>(null);
const editorContainerRef = useRef<HTMLDivElement | null>(null);
const providerRef = useRef<HocuspocusProvider | null>(null);
const publishDialogRef = useRef<HTMLDialogElement>(null);
const [editorInstance, setEditorInstance] = useState<TiptapEditor | null>(null);
const [containerEl, setContainerEl] = useState<HTMLElement | null>(null);
const [commentStore, setCommentStore] = useState<CommentStore | null>(null);
const [frontmatterStore, setFrontmatterStore] = useState<FrontmatterStore | null>(null);
const [embedStore, setEmbedStore] = useState<EmbedStore | null>(null);
const [embedDataStore, setEmbedDataStore] = useState<EmbedDataStore | null>(null);
const [settingsMap, setSettingsMap] = useState<Y.Map<any> | null>(null);
const [undoManager, setUndoManager] = useState<UndoManager | null>(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<string | null>(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<string | null>(null);
const [tocSidebarOpen, setTocSidebarOpen] = useState(false);
const [tocAutoCollapse, setTocAutoCollapse] = useState(false);
const [publishState, setPublishState] = useState<PublishState>("idle");
const [publishError, setPublishError] = useState("");
const [publishStage, setPublishStage] = useState<PublishStageEvent | null>(null);
const [yDoc, setYDoc] = useState<Y.Doc | null>(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<ModelOption[]>([]);
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<string | null>(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 "<Name> 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 "<Name> 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 <bibliography> 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<string, unknown>)
.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<any>) => setSettingsMap(map), []);
// --- Publish flow -----------------------------------------------------
const publishEventSourceRef = useRef<EventSource | null>(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 (
<div
className={`editor-app${chatOpen ? " editor-app--chat-open" : ""}`}
style={
{
// Expose the current user's color (with ~44% alpha) as a CSS
// variable so the global `::selection` rule can recolor every
// native selection in the app with the user's identity color.
// Using the native selection keeps the full line-height of the
// selected text (no more "shorter than native" custom tint).
//
// When `editor-app--chat-open` is active the native selection
// is hidden *inside the editor only* (see `_ui.css`) so the
// AgentFocus PM decoration can take over as the single source
// of truth for the selected range - this avoids stacking two
// tints when the editor still has DOM focus after opening the
// chat panel.
"--local-selection-bg": `${user.color}70`,
} as React.CSSProperties
}
>
<TopBar
editorInstance={editorInstance}
providerRef={providerRef}
docName={docName}
theme={theme}
user={user}
loginUrl={loginUrl}
isAuthenticated={isAuthenticated}
canEdit={canEdit}
isPublishing={publishStatus.active}
publishingUserName={publishStatus.userName}
onToggleTheme={toggleTheme}
onOpenSettings={() => setSettingsOpen(true)}
onOpenPublish={openPublishDialog}
onOpenMobileToc={() => setTocSidebarOpen(true)}
onOpenEmbedStudio={handleOpenEmbedStudio}
/>
{!isEditorReady && (
<div className="editor-loading-overlay">
<span className="spinner spinner--lg" />
</div>
)}
<div
ref={editorContainerCallback}
className={isEditorReady ? "editor-scroll editor-scroll--ready" : "editor-scroll"}
>
<div className="content-grid">
<div className="content-grid__hero">
<FrontmatterHero store={frontmatterStore} embedStore={embedStore} />
</div>
<div className="content-grid__toc">
<div className="table-of-contents--sticky">
<TableOfContents
editor={editorInstance}
scrollContainer={containerEl}
autoCollapse={tocAutoCollapse}
/>
</div>
</div>
<div className="content-grid__editor">
<Editor
docName={docName}
user={user}
editorRef={editorRef}
onCommentStoreReady={onCommentStoreReady}
onFrontmatterStoreReady={onFrontmatterStoreReady}
onEmbedStoreReady={onEmbedStoreReady}
onEmbedDataStoreReady={onEmbedDataStoreReady}
onSettingsMapReady={onSettingsMapReady}
onEditorReady={onEditorReady}
onUndoManagerReady={setUndoManager}
onProviderReady={onProviderReady}
onAddComment={handleAddComment}
/>
</div>
</div>
<EditorFooter store={frontmatterStore} editor={editorInstance} />
<CommentMarginIcons
editor={editorInstance}
commentStore={commentStore}
user={user}
/>
</div>
{chatOpen ? (
<div className="chat-floating">
<ChatPanel
messages={agentChat.messages}
isLoading={agentChat.isLoading}
error={agentChat.error}
input={agentChat.input}
models={models}
selectedModel={selectedModel}
onModelChange={handleModelChange}
onSend={agentChat.sendMessage}
onSetInput={agentChat.setInput}
onStop={agentChat.stop}
onNewChat={() => agentChat.clearMessages()}
onClose={() => setChatOpen(false)}
/>
</div>
) : (
<Tooltip title="AI Assistant" placement="right">
<button
className={`chat-fab ${agentChat.isLoading ? "badge-dot" : "badge-dot badge-dot--hidden"}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setChatOpen(true)}
aria-label="AI Assistant"
>
<MessageCircle size={22} />
</button>
</Tooltip>
)}
<CommentDialog
open={commentDialogOpen}
onClose={() => setCommentDialogOpen(false)}
onSubmit={handleCommentSubmit}
/>
<SettingsDrawer
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
store={frontmatterStore}
settingsMap={settingsMap}
/>
{embedStudioSrc && (
<EmbedStudio
key={embedStudioSession ?? embedStudioSrc}
src={embedStudioSrc}
embedStore={embedStore}
dataStore={embedDataStore}
modelRef={modelRef}
userId={chatUserId}
onClose={() => {
setEmbedStudioSrc(null);
setEmbedStudioSession(null);
}}
onRename={handleEmbedRename}
onSelectChart={(name) => {
if (name === embedStudioSrc) return;
setEmbedStudioSrc(name);
// Rotate the session key so the studio remounts with a
// fresh chat scope (loadMessages picks up the persisted
// history of the newly-selected file).
setEmbedStudioSession(
`es-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
);
}}
/>
)}
<MobileTocSidebar
open={tocSidebarOpen}
editor={editorInstance}
scrollContainer={containerEl}
autoCollapse={tocAutoCollapse}
theme={theme}
onClose={() => setTocSidebarOpen(false)}
onToggleTheme={toggleTheme}
/>
<PublishDialog
ref={publishDialogRef}
state={publishState}
error={publishError}
docName={docName}
stageEvent={publishStage}
onClose={closePublishDialog}
onPublish={handlePublish}
/>
</div>
);
}