diff --git a/apps/web/src/components/WritingView.tsx b/apps/web/src/components/WritingView.tsx
index bdffd5a..ae961ad 100644
--- a/apps/web/src/components/WritingView.tsx
+++ b/apps/web/src/components/WritingView.tsx
@@ -80,6 +80,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
const autosave = useProjectStore((s) => s.autosave);
const entities = useProjectStore((s) => s.entities);
const highlightedLine = useProjectStore((s) => s.highlightedLine);
+ const highlightSeq = useProjectStore((s) => s.highlightSeq);
const docExternalRev = useProjectStore((s) => s.docExternalRev);
const fontFamily = useSettings((st) => st.fontFamily);
@@ -134,32 +135,29 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
// Sync content when the active chapter changes or when an AI suggestion edit is applied.
useEffect(() => {
if (!editor || !chapter) return;
- editor.commands.setContent(chapter.doc, { emitUpdate: false });
- editor.commands.focus();
+ try {
+ editor.commands.setContent(chapter.doc, { emitUpdate: false });
+ } catch {
+ /* ignore invalid intermediate content */
+ }
}, [editor, chapter?.id, docExternalRev]);
- // Highlight a specific 1-based line block in the editor when an AI suggestion targets it.
+ // Scroll the highlighted 1-based line block into view when an AI suggestion targets it.
+ // Visual highlighting uses CSS `:nth-child(N)` via `data-highlight-line` on `.ps-editor`
+ // so ProseMirror's internal DOMObserver never strips the highlight class.
useEffect(() => {
const editorDom = editorDomRef.current;
- if (!editorDom) return undefined;
+ if (!editorDom || highlightedLine == null || highlightedLine < 1) return undefined;
const pmRoot = editorDom.querySelector(".ProseMirror");
if (!pmRoot) return undefined;
const children = Array.from(pmRoot.children) as HTMLElement[];
- children.forEach((el) => el.classList.remove("ps-ai-line-highlight"));
-
- if (highlightedLine != null && highlightedLine >= 1 && highlightedLine <= children.length) {
- const targetEl = children[highlightedLine - 1];
- if (targetEl) {
- targetEl.classList.add("ps-ai-line-highlight");
- targetEl.scrollIntoView({ behavior: "smooth", block: "center" });
- }
+ const targetEl = children[highlightedLine - 1];
+ if (targetEl && typeof targetEl.scrollIntoView === "function") {
+ targetEl.scrollIntoView({ behavior: "smooth", block: "center" });
}
-
- return () => {
- children.forEach((el) => el.classList.remove("ps-ai-line-highlight"));
- };
- }, [highlightedLine, docExternalRev, chapter?.id]);
+ return undefined;
+ }, [editor, highlightedLine, highlightSeq, docExternalRev, chapter?.id]);
// Focus mode: dim every top-level block in .ProseMirror except the one
// containing the active cursor selection.
@@ -859,7 +857,28 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
className={`ps-editor-scroll${focusMode ? " ps-editor-focus" : ""}${showLineNumbers || highlightedLine != null ? " ps-editor-linenumbers" : ""}${firstLineIndent ? " ps-editor-indent" : ""}`}
>
-
+ {highlightedLine != null && highlightedLine >= 1 && (
+
+ )}
+
{
if (!projectId) return undefined;
- const store = new AIStore(projectId, createIndexedDBPersistence());
+ const persistence =
+ typeof indexedDB !== "undefined"
+ ? createIndexedDBPersistence()
+ : createInMemoryPersistence();
+ const store = new AIStore(projectId, persistence);
setAiStore(store);
return () => {
void store.dispose();
@@ -440,7 +449,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
lineNumber: adjusted.lineNumber,
...(adjusted.originalText ? { originalText: adjusted.originalText } : {}),
};
- if (aiStore && activeSessionId) {
+ if (aiStore && activeSessionId && typeof aiStore.updateSuggestion === "function") {
void aiStore.updateSuggestion(activeSessionId, sg.id, {
lineNumber: adjusted.lineNumber,
...(adjusted.originalText ? { originalText: adjusted.originalText } : {}),
@@ -461,7 +470,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
chapterId: currentChapterId,
lines: chapterLines,
};
- }, [chapterLines, activeChapter?.id, suggestions, aiStore, activeSessionId, highlightedLine, setHighlightedLine]);
+ }, [chapterLines, activeChapter?.id]);
// Sidebar drag-resize handler with snap-to-default (370px)
const handleStartResize = (e: React.MouseEvent) => {
@@ -632,10 +641,12 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
}, [reloadAll]);
useEffect(() => {
- scrollRef.current?.scrollTo({
- top: scrollRef.current.scrollHeight,
- behavior: "smooth",
- });
+ if (scrollRef.current && typeof scrollRef.current.scrollTo === "function") {
+ scrollRef.current.scrollTo({
+ top: scrollRef.current.scrollHeight,
+ behavior: "smooth",
+ });
+ }
}, [messages, sending, thinkingStage]);
const handleLoadReload = async () => {
@@ -894,6 +905,15 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim();
if (!replacement) return;
+ // Remove the accepted suggestion from state and persistence first so line
+ // re-anchoring only runs on the remaining pending suggestions.
+ setSuggestions((prev) => prev.filter((item) => item.id !== sg.id));
+ if (typeof aiStore.deleteSuggestion === "function") {
+ await aiStore.deleteSuggestion(activeSessionId, sg.id);
+ } else {
+ await aiStore.resolveSuggestion(activeSessionId, sg.id, true);
+ }
+
const applied = await applyLineEdit({
...(sg.chapterId ? { chapterId: sg.chapterId } : {}),
...(sg.lineNumber != null ? { lineNumber: sg.lineNumber } : {}),
@@ -901,7 +921,6 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
newText: replacement,
});
- await aiStore.deleteSuggestion(activeSessionId, sg.id);
const nextList = await aiStore.listSuggestions(activeSessionId);
setSuggestions(nextList);
@@ -914,7 +933,12 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
const handleDenySuggestion = async (sg: AISuggestion) => {
if (!aiStore || !activeSessionId) return;
- await aiStore.deleteSuggestion(activeSessionId, sg.id);
+ setSuggestions((prev) => prev.filter((item) => item.id !== sg.id));
+ if (typeof aiStore.deleteSuggestion === "function") {
+ await aiStore.deleteSuggestion(activeSessionId, sg.id);
+ } else {
+ await aiStore.resolveSuggestion(activeSessionId, sg.id, false);
+ }
const nextList = await aiStore.listSuggestions(activeSessionId);
setSuggestions(nextList);
if (highlightedLine === sg.lineNumber) {
@@ -960,11 +984,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
const handleHighlightLine = (lineNum: number | undefined) => {
if (!lineNum) return;
- if (highlightedLine === lineNum) {
- setHighlightedLine(null);
- } else {
- setHighlightedLine(lineNum);
- }
+ setHighlightedLine(lineNum);
};
const renderMessageWithLineLinks = (content: string) => {
diff --git a/apps/web/src/features/ai/ai-accept.test.tsx b/apps/web/src/features/ai/ai-accept.test.tsx
new file mode 100644
index 0000000..1160a82
--- /dev/null
+++ b/apps/web/src/features/ai/ai-accept.test.tsx
@@ -0,0 +1,136 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { createRoot } from "react-dom/client";
+import { act } from "react";
+import { MemoryRouter } from "react-router-dom";
+import type { Chapter, ProjectMeta, StorageAdapter } from "@pubscript/core";
+import { useProjectStore } from "../../store/project-store.js";
+import { WritingView } from "../../components/WritingView.js";
+import { AISidebar } from "./AISidebar.js";
+
+describe("AISidebar + WritingView accept suggestion flow", () => {
+ beforeEach(() => {
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ Element.prototype.scrollIntoView = () => {};
+ Range.prototype.getClientRects = () =>
+ ({
+ length: 0,
+ item: () => null,
+ [Symbol.iterator]: function* () {},
+ }) as unknown as DOMRectList;
+ Range.prototype.getBoundingClientRect = () =>
+ ({ top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("offline"));
+
+ const ch: Chapter = {
+ id: "ch-1",
+ bookId: "book-1",
+ projectId: "proj-1",
+ title: "Chapter One",
+ order: 0,
+ rev: 1,
+ meta: { wordCount: 10, scenes: [], updatedAt: new Date().toISOString() },
+ doc: {
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ {
+ type: "text",
+ text: "The ferry that carried Mara Vell to the Glass Harbor left the mainland before dawn.",
+ },
+ ],
+ },
+ {
+ type: "paragraph",
+ content: [
+ {
+ type: "text",
+ text: "She pressed her palm against the cold window and watched the lighthouse appear out of the mist.",
+ },
+ ],
+ },
+ {
+ type: "paragraph",
+ content: [
+ {
+ type: "text",
+ text: "Three years since she had last seen it, and yet it seemed to have shrunk.",
+ },
+ ],
+ },
+ ],
+ },
+ };
+ const mockAdapter = {
+ async saveDocument() {},
+ } as unknown as StorageAdapter;
+
+ useProjectStore.setState({
+ adapter: mockAdapter,
+ project: { id: "proj-1", title: "Test Project" } as ProjectMeta,
+ chapters: [ch],
+ activeChapterId: "ch-1",
+ highlightedLine: null,
+ docExternalRev: 0,
+ status: "ready",
+ });
+ });
+
+ it("generates line suggestions, highlights line on click, and accepts one without crashing", async () => {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 80));
+ });
+
+ const genBtn = Array.from(container.querySelectorAll("button")).find((b) =>
+ b.textContent?.includes("Suggest Line Edits for Chapter"),
+ );
+ expect(genBtn).toBeDefined();
+
+ await act(async () => {
+ genBtn!.click();
+ });
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 1600));
+ });
+
+ const acceptBtn = Array.from(container.querySelectorAll("button")).find((b) =>
+ b.textContent?.includes("Accept"),
+ );
+ if (!acceptBtn) {
+ console.log("BUTTONS:", Array.from(container.querySelectorAll("button")).map((b) => b.textContent));
+ }
+ expect(acceptBtn).toBeDefined();
+
+ await act(async () => {
+ acceptBtn!.click();
+ await new Promise((r) => setTimeout(r, 150));
+ });
+
+ const pm = container.querySelector(".ProseMirror");
+ expect(pm?.textContent).toContain("sharp and unmistakable");
+
+ await act(async () => {
+ root.unmount();
+ });
+ container.remove();
+ });
+});
diff --git a/apps/web/src/store/project-store.ts b/apps/web/src/store/project-store.ts
index cdb6b61..310d0f7 100644
--- a/apps/web/src/store/project-store.ts
+++ b/apps/web/src/store/project-store.ts
@@ -70,6 +70,7 @@ interface ProjectStore {
chapters: Chapter[];
activeChapterId: string | null;
highlightedLine: number | null;
+ highlightSeq: number;
docExternalRev: number;
status: "idle" | "loading" | "ready";
error: string | null;
@@ -204,6 +205,7 @@ export const useProjectStore = create((set, get) => {
chapters: [],
activeChapterId: null,
highlightedLine: null,
+ highlightSeq: 0,
docExternalRev: 0,
status: "idle",
error: null,
@@ -538,7 +540,10 @@ export const useProjectStore = create((set, get) => {
},
setHighlightedLine: (line) => {
- set({ highlightedLine: line });
+ set((state) => ({
+ highlightedLine: line,
+ highlightSeq: state.highlightSeq + 1,
+ }));
},
applyLineEdit: async ({ chapterId, lineNumber, originalText, newText }) => {
@@ -591,18 +596,28 @@ export const useProjectStore = create((set, get) => {
const oldBlock = blocks[targetIndex]! as unknown as Record;
const oldFullText = extractBlockText(oldBlock);
- const updatedLineText =
+ const updatedLineText = (
originalText &&
originalText.trim() &&
oldFullText.includes(originalText.trim()) &&
oldFullText.trim() !== originalText.trim()
? oldFullText.replace(originalText.trim(), newText)
- : newText;
+ : newText
+ ).trim();
+
+ const safeType =
+ oldBlock.type === "heading" || oldBlock.type === "codeBlock"
+ ? (oldBlock.type as string)
+ : "paragraph";
blocks[targetIndex] = {
- ...blocks[targetIndex]!,
- type: (oldBlock.type as string) || "paragraph",
- content: [{ type: "text", text: updatedLineText }],
+ type: safeType,
+ ...(oldBlock.attrs && typeof oldBlock.attrs === "object"
+ ? { attrs: oldBlock.attrs as Record }
+ : {}),
+ ...(updatedLineText
+ ? { content: [{ type: "text", text: updatedLineText }] }
+ : {}),
};
const nextDoc = {
@@ -622,6 +637,7 @@ export const useProjectStore = create((set, get) => {
set((state) => ({
activeChapterId: targetChapter.id,
highlightedLine: targetIndex + 1,
+ highlightSeq: state.highlightSeq + 1,
docExternalRev: state.docExternalRev + 1,
chapters: state.chapters.map((c) =>
c.id === targetChapter.id ? { ...c, doc: nextDoc, rev: c.rev + 1 } : c,
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 5212a9f..ffce22e 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -3,6 +3,15 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
+ optimizeDeps: {
+ exclude: [
+ "@pubscript/core",
+ "@pubscript/ai",
+ "@pubscript/editor",
+ "@pubscript/ui",
+ "@pubscript/exporters",
+ ],
+ },
server: {
host: "0.0.0.0",
port: 5173,
diff --git a/packages/editor/src/decorations/check-service.ts b/packages/editor/src/decorations/check-service.ts
index 3dc162c..e675c36 100644
--- a/packages/editor/src/decorations/check-service.ts
+++ b/packages/editor/src/decorations/check-service.ts
@@ -142,6 +142,7 @@ export class CheckService {
async check(): Promise {
if (!this.editor) return { issues: [], counts: this.countsSilent(), checking: false };
const result = await this.runChecks(this.editor.state);
+ if (!this.editor) return { issues: [], counts: this.countsSilent(), checking: false };
this.issues = result.issues;
this.decorations = buildCheckDecorations(this.editor.state, result.blocks);
const checkResult: CheckResult = {