feat: peg AI inline suggestions to granular text spans, add autosave revision history with restore, fix focus mode, and make PS logo background transparent

This commit is contained in:
Schwitzz
2026-10-30 12:49:43 -05:00
parent f40f23dcb9
commit 951aa81055
11 changed files with 1093 additions and 424 deletions
+2 -8
View File
@@ -1,9 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="PubScript"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 112 512 288" width="512" height="288" role="img" aria-label="PubScript">
<defs> <defs>
<radialGradient id="ps-bg" cx="48%" cy="40%" r="78%">
<stop offset="0" stop-color="#2b313d"/>
<stop offset="1" stop-color="#15181f"/>
</radialGradient>
<linearGradient id="ps-gold" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="ps-gold" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f9edc4"/> <stop offset="0" stop-color="#f9edc4"/>
<stop offset="0.27" stop-color="#e8c76a"/> <stop offset="0.27" stop-color="#e8c76a"/>
@@ -17,12 +13,10 @@
<stop offset="1" stop-color="#e8dcbd"/> <stop offset="1" stop-color="#e8dcbd"/>
</radialGradient> </radialGradient>
<filter id="ps-shadow" x="-35%" y="-35%" width="170%" height="170%"> <filter id="ps-shadow" x="-35%" y="-35%" width="170%" height="170%">
<feDropShadow dx="0" dy="7" stdDeviation="11" flood-color="#000000" flood-opacity="0.45"/> <feDropShadow dx="0" dy="6" stdDeviation="9" flood-color="#000000" flood-opacity="0.35"/>
</filter> </filter>
</defs> </defs>
<rect width="512" height="512" rx="104" fill="url(#ps-bg)"/>
<g filter="url(#ps-shadow)"> <g filter="url(#ps-shadow)">
<!-- P tile --> <!-- P tile -->
<circle cx="126" cy="256" r="120" fill="url(#ps-gold)"/> <circle cx="126" cy="256" r="120" fill="url(#ps-gold)"/>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

+2 -1
View File
@@ -31,6 +31,7 @@ export default function App() {
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const navCollapsed = useSettings((s) => s.navCollapsed); const navCollapsed = useSettings((s) => s.navCollapsed);
const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen); const aiSidebarOpen = useSettings((s) => s.aiSidebarOpen);
const focusMode = useSettings((s) => s.focusMode);
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -44,7 +45,7 @@ export default function App() {
}, []); }, []);
return ( return (
<div className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}`}> <div className={`ps-app${navCollapsed ? " is-nav-collapsed" : ""}${aiSidebarOpen ? " is-ai-open" : ""}${focusMode ? " is-focus-mode" : ""}`}>
<SplashScreen /> <SplashScreen />
<Sidebar nav={NAV} /> <Sidebar nav={NAV} />
<Topbar onOpenPalette={() => setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} /> <Topbar onOpenPalette={() => setPaletteOpen(true)} onOpenExport={() => setExportOpen(true)} />
+73
View File
@@ -38,17 +38,36 @@ function totalWords(chapters: ChapterDoc[]): number {
return total; return total;
} }
function formatRevTime(iso: string): string {
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
} catch {
return "";
}
}
export function Sidebar({ nav }: SidebarProps) { export function Sidebar({ nav }: SidebarProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const projects = useProjectStore((s) => s.projects); const projects = useProjectStore((s) => s.projects);
const project = useProjectStore((s) => s.project); const project = useProjectStore((s) => s.project);
const openProject = useProjectStore((s) => s.openProject); const openProject = useProjectStore((s) => s.openProject);
const chapters = useProjectStore((s) => s.chapters); const chapters = useProjectStore((s) => s.chapters);
const activeChapterId = useProjectStore((s) => s.activeChapterId);
const revisionsByChapter = useProjectStore((s) => s.revisionsByChapter);
const restoreRevision = useProjectStore((s) => s.restoreRevision);
const navCollapsed = useSettings((s) => s.navCollapsed); const navCollapsed = useSettings((s) => s.navCollapsed);
const autoSave = useSettings((s) => s.autoSave);
const setSettings = useSettings((s) => s.set); const setSettings = useSettings((s) => s.set);
const title = project?.title ?? "Untitled project"; const title = project?.title ?? "Untitled project";
const wordCount = totalWords(chapters); const wordCount = totalWords(chapters);
const activeChapter =
chapters.find((c) => c.id === activeChapterId) ?? chapters[0] ?? null;
const activeRevisions = activeChapter
? (revisionsByChapter[activeChapter.id] ?? [])
: [];
const handleSelectProject = async (id: string) => { const handleSelectProject = async (id: string) => {
if (!id || id === project?.id) return; if (!id || id === project?.id) return;
@@ -59,6 +78,14 @@ export function Sidebar({ nav }: SidebarProps) {
} }
}; };
const handleRestoreRev = async (revId: string, revNum: number) => {
if (!activeChapter) return;
const ok = await restoreRevision(activeChapter.id, revId);
if (ok) {
toast.success(`Restored autosave rev #${revNum} for "${activeChapter.title}"`);
}
};
return ( return (
<aside className={`ps-sidebar${navCollapsed ? " is-collapsed" : ""}`}> <aside className={`ps-sidebar${navCollapsed ? " is-collapsed" : ""}`}>
<div className="ps-sidebar-header"> <div className="ps-sidebar-header">
@@ -148,6 +175,52 @@ export function Sidebar({ nav }: SidebarProps) {
</NavLink> </NavLink>
))} ))}
</nav> </nav>
{!navCollapsed && autoSave && activeChapter && (
<div className="ps-sidebar-revisions">
<div className="ps-nav-section-title" style={{ marginTop: 14, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span>Autosave Revisions</span>
<span style={{ fontFamily: "var(--ps-font-mono)", fontSize: 10 }}>
rev #{activeChapter.rev || 1}
</span>
</div>
<div className="ps-sidebar-revisions-sub">
{activeChapter.title}
</div>
<div className="ps-sidebar-revisions-list">
{activeRevisions.length === 0 ? (
<div style={{ fontSize: 11, color: "var(--ps-text-muted)", padding: "4px 2px" }}>
No autosave revisions yet.
</div>
) : (
activeRevisions.slice(0, 8).map((rev, idx) => (
<div key={rev.id} className="ps-sidebar-rev-item">
<div className="ps-sidebar-rev-info">
<span className="ps-sidebar-rev-num">rev #{rev.rev}</span>
<span className="ps-sidebar-rev-meta">
{rev.wordCount}w · {formatRevTime(rev.savedAt)}
</span>
</div>
{idx === 0 ? (
<span className="ps-sidebar-rev-current" title={rev.label}>
Current
</span>
) : (
<button
type="button"
className="ps-sidebar-rev-restore"
title={`Restore ${activeChapter.title} to autosave rev #${rev.rev} (${rev.wordCount} words)`}
onClick={() => void handleRestoreRev(rev.id, rev.rev)}
>
↺ Restore rev #{rev.rev}
</button>
)}
</div>
))
)}
</div>
</div>
)}
</aside> </aside>
); );
} }
+109 -75
View File
@@ -80,6 +80,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
const autosave = useProjectStore((s) => s.autosave); const autosave = useProjectStore((s) => s.autosave);
const entities = useProjectStore((s) => s.entities); const entities = useProjectStore((s) => s.entities);
const highlightedLine = useProjectStore((s) => s.highlightedLine); const highlightedLine = useProjectStore((s) => s.highlightedLine);
const highlightedText = useProjectStore((s) => s.highlightedText);
const highlightSeq = useProjectStore((s) => s.highlightSeq); const highlightSeq = useProjectStore((s) => s.highlightSeq);
const docExternalRev = useProjectStore((s) => s.docExternalRev); const docExternalRev = useProjectStore((s) => s.docExternalRev);
@@ -95,6 +96,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
const [showContextRail, setShowContextRail] = useState(true); const [showContextRail, setShowContextRail] = useState(true);
const [renamingChapter, setRenamingChapter] = useState(false); const [renamingChapter, setRenamingChapter] = useState(false);
const [chapterTitleDraft, setChapterTitleDraft] = useState(""); const [chapterTitleDraft, setChapterTitleDraft] = useState("");
const [resolvedDomChildIdx, setResolvedDomChildIdx] = useState<number | null>(null);
const [, setToolbarTick] = useState(0); const [, setToolbarTick] = useState(0);
const editorDomRef = useRef<HTMLDivElement | null>(null); const editorDomRef = useRef<HTMLDivElement | null>(null);
@@ -142,82 +144,114 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
} }
}, [editor, chapter?.id, docExternalRev]); }, [editor, chapter?.id, docExternalRev]);
// Scroll the highlighted 1-based line block into view when an AI suggestion targets it. // Highlight the target text span (via CSS Custom Highlight API) and resolve the
// Visual highlighting uses CSS `:nth-child(N)` via `data-highlight-line` on `.ps-editor` // exact 1-based non-empty DOM child block in .ProseMirror so blank lines never
// so ProseMirror's internal DOMObserver never strips the highlight class. // shift or misalign suggestion highlights.
useEffect(() => { useEffect(() => {
const editorDom = editorDomRef.current; const clearCustomHighlight = () => {
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[];
const targetEl = children[highlightedLine - 1];
if (targetEl && typeof targetEl.scrollIntoView === "function") {
targetEl.scrollIntoView({ behavior: "smooth", block: "center" });
}
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.
useEffect(() => {
if (!editor) return;
const editorDom = editorDomRef.current;
if (!editorDom) return;
const clearFocusClasses = () => {
const pmRoot = editorDom.querySelector(".ProseMirror");
if (!pmRoot) return;
Array.from(pmRoot.children).forEach((child) => {
child.classList.remove("ps-focus-active");
});
};
if (!focusMode) {
clearFocusClasses();
return undefined;
}
const applyFocus = () => {
const pmRoot = editorDom.querySelector(".ProseMirror");
if (!pmRoot) return;
let activeEl: HTMLElement | null = null;
try { try {
const pos = editor.state.selection.$from.pos; const cssAny = globalThis.CSS as unknown as {
const domInfo = editor.view.domAtPos(pos); highlights?: { delete: (name: string) => void };
let node: Node | null = domInfo.node; };
if (node && node.nodeType === Node.TEXT_NODE) { cssAny?.highlights?.delete("ps-ai-target");
node = node.parentElement;
}
while (node && node instanceof HTMLElement && node !== pmRoot) {
if (node.parentElement === pmRoot) {
activeEl = node;
break;
}
node = node.parentElement;
}
} catch { } catch {
/* ignore */ /* ignore */
} }
const children = Array.from(pmRoot.children) as HTMLElement[];
if (!activeEl && children.length > 0) {
activeEl = children[0]!;
}
children.forEach((child) => {
child.classList.toggle("ps-focus-active", child === activeEl);
});
}; };
editor.on("selectionUpdate", applyFocus); clearCustomHighlight();
editor.on("update", applyFocus); const editorDom = editorDomRef.current;
applyFocus(); if (!editorDom) {
return () => { setResolvedDomChildIdx(null);
editor.off("selectionUpdate", applyFocus); return undefined;
editor.off("update", applyFocus); }
clearFocusClasses(); const pmRoot = editorDom.querySelector(".ProseMirror");
if (!pmRoot) {
setResolvedDomChildIdx(null);
return undefined;
}
const children = Array.from(pmRoot.children) as HTMLElement[];
const normQuotes = (s: string) =>
s.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'");
const needle = (highlightedText ?? "").trim().replace(/^["“]|["”]$/g, "").trim();
if (needle) {
const normNeedle = normQuotes(needle).toLowerCase();
for (let i = 0; i < children.length; i++) {
const childEl = children[i]!;
const textNodes: { node: Text; start: number; end: number }[] = [];
let full = "";
const walker = document.createTreeWalker(childEl, NodeFilter.SHOW_TEXT);
let cur = walker.nextNode();
while (cur) {
const t = cur as Text;
const len = t.nodeValue?.length ?? 0;
if (len > 0) {
textNodes.push({ node: t, start: full.length, end: full.length + len });
full += t.nodeValue;
}
cur = walker.nextNode();
}
if (!full.trim()) continue;
const matchIdx = normQuotes(full).toLowerCase().indexOf(normNeedle);
if (matchIdx !== -1) {
const matchEnd = matchIdx + normNeedle.length;
setResolvedDomChildIdx(i + 1);
if (typeof childEl.scrollIntoView === "function") {
childEl.scrollIntoView({ behavior: "smooth", block: "center" });
}
// Highlight the exact inline phrase/clause/sentence via CSS.highlights
try {
const startEntry = textNodes.find((n) => matchIdx >= n.start && matchIdx < n.end);
const endEntry = textNodes.find((n) => matchEnd > n.start && matchEnd <= n.end);
if (startEntry && endEntry && typeof Range !== "undefined") {
const range = document.createRange();
range.setStart(startEntry.node, matchIdx - startEntry.start);
range.setEnd(endEntry.node, matchEnd - endEntry.start);
const HighlightCtor = (globalThis as unknown as {
Highlight?: new (...ranges: Range[]) => unknown;
}).Highlight;
const cssAny = globalThis.CSS as unknown as {
highlights?: { set: (name: string, h: unknown) => void };
}; };
}, [editor, focusMode]); if (HighlightCtor && cssAny?.highlights) {
cssAny.highlights.set("ps-ai-target", new HighlightCtor(range));
}
}
} catch {
/* ignore if CSS Custom Highlight API is unavailable */
}
return clearCustomHighlight;
}
}
}
if (highlightedLine != null && highlightedLine >= 1) {
// Map highlightedLine to the Nth non-empty block so blank lines are skipped
const nonEmptyDomIndices: number[] = [];
children.forEach((el, idx) => {
if ((el.textContent ?? "").trim().length > 0) {
nonEmptyDomIndices.push(idx + 1);
}
});
const domIdx =
nonEmptyDomIndices[highlightedLine - 1] ??
(highlightedLine <= children.length ? highlightedLine : null);
setResolvedDomChildIdx(domIdx);
const targetEl = domIdx ? children[domIdx - 1] : null;
if (targetEl && typeof targetEl.scrollIntoView === "function") {
targetEl.scrollIntoView({ behavior: "smooth", block: "center" });
}
return clearCustomHighlight;
}
setResolvedDomChildIdx(null);
return clearCustomHighlight;
}, [editor, highlightedText, highlightedLine, highlightSeq, docExternalRev, chapter?.id]);
// Keyboard shortcut: F2 toggles focus mode. // Keyboard shortcut: F2 toggles focus mode.
useEffect(() => { useEffect(() => {
@@ -854,20 +888,20 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
{/* Main Editor Body + Context Rail */} {/* Main Editor Body + Context Rail */}
<div className="ps-writing-body"> <div className="ps-writing-body">
<div <div
className={`ps-editor-scroll${focusMode ? " ps-editor-focus" : ""}${showLineNumbers || highlightedLine != null ? " ps-editor-linenumbers" : ""}${firstLineIndent ? " ps-editor-indent" : ""}`} className={`ps-editor-scroll${focusMode ? " ps-editor-focus" : ""}${showLineNumbers ? " ps-editor-linenumbers" : ""}${firstLineIndent ? " ps-editor-indent" : ""}`}
> >
<div className="ps-editor-writing"> <div className="ps-editor-writing">
{highlightedLine != null && highlightedLine >= 1 && ( {resolvedDomChildIdx != null && resolvedDomChildIdx >= 1 && (
<style>{` <style>{`
.ps-editor[data-highlight-line="${highlightedLine}"] .ProseMirror > *:nth-child(${highlightedLine}) { .ps-editor[data-highlight-line="${resolvedDomChildIdx}"] .ProseMirror > *:nth-child(${resolvedDomChildIdx}) {
background: color-mix(in srgb, var(--ps-accent) 24%, transparent) !important; background: color-mix(in srgb, var(--ps-accent) 18%, transparent) !important;
outline: 2px solid var(--ps-accent) !important; outline: 2px solid var(--ps-accent) !important;
outline-offset: 4px !important; outline-offset: 4px !important;
border-radius: 6px !important; border-radius: 6px !important;
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ps-accent) 14%, transparent) !important; box-shadow: 0 0 0 4px color-mix(in srgb, var(--ps-accent) 12%, transparent) !important;
transition: background 0.18s ease, outline 0.18s ease; transition: background 0.18s ease, outline 0.18s ease;
} }
.ps-editor-linenumbers .ps-editor[data-highlight-line="${highlightedLine}"] .ProseMirror > *:nth-child(${highlightedLine})::before { .ps-editor-linenumbers .ps-editor[data-highlight-line="${resolvedDomChildIdx}"] .ProseMirror > *:nth-child(${resolvedDomChildIdx})::before {
color: var(--ps-accent) !important; color: var(--ps-accent) !important;
font-weight: 800 !important; font-weight: 800 !important;
opacity: 1 !important; opacity: 1 !important;
@@ -877,7 +911,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
<div <div
className="ps-editor" className="ps-editor"
ref={editorDomRef} ref={editorDomRef}
data-highlight-line={highlightedLine ?? undefined} data-highlight-line={resolvedDomChildIdx ?? undefined}
> >
<EditorContent editor={editor} /> <EditorContent editor={editor} />
<MentionPopover <MentionPopover
+287 -220
View File
@@ -43,7 +43,8 @@ function extractChapterLines(
doc: { content?: Array<Record<string, unknown>> } | undefined, doc: { content?: Array<Record<string, unknown>> } | undefined,
): ChapterLine[] { ): ChapterLine[] {
if (!doc || !Array.isArray(doc.content)) return []; if (!doc || !Array.isArray(doc.content)) return [];
return doc.content.map((block, idx) => { const out: ChapterLine[] = [];
for (const block of doc.content) {
const parts: string[] = []; const parts: string[] = [];
const walk = (node: Record<string, unknown>) => { const walk = (node: Record<string, unknown>) => {
if (typeof node.text === "string") parts.push(node.text); if (typeof node.text === "string") parts.push(node.text);
@@ -54,109 +55,47 @@ function extractChapterLines(
} }
}; };
walk(block); walk(block);
return { const trimmed = parts.join("").trim();
lineNumber: idx + 1, if (trimmed.length > 0) {
text: parts.join("").trim(), out.push({
}; lineNumber: out.length + 1,
text: trimmed,
}); });
}
/** Word-level similarity (0..1) to match an edited block back to a suggestion. */
function wordSimilarity(a: string, b: string): number {
const wordsA = a
.toLowerCase()
.split(/\s+/)
.map((w) => w.replace(/[^a-z0-9]/g, ""))
.filter((w) => w.length > 2);
const wordsB = b
.toLowerCase()
.split(/\s+/)
.map((w) => w.replace(/[^a-z0-9]/g, ""))
.filter((w) => w.length > 2);
if (wordsA.length === 0 || wordsB.length === 0) return 0;
const setB = new Set(wordsB);
let shared = 0;
for (const w of wordsA) {
if (setB.has(w)) shared += 1;
} }
return shared / Math.max(wordsA.length, wordsB.length); }
return out;
} }
/** /**
* Re-anchor a suggestion's 1-based lineNumber when blocks are inserted, * Re-anchor a suggestion's 1-based non-empty paragraph index when blocks are
* deleted, or edited in the inline editor. * inserted, deleted, or edited in the inline editor, while preserving the
* granular `originalText` phrase/sentence span intact.
*/ */
function resolveAdjustedLine( function resolveAdjustedLine(
sg: AISuggestion, sg: AISuggestion,
currentLines: ChapterLine[], currentLines: ChapterLine[],
prevLines: ChapterLine[], _prevLines: ChapterLine[],
): { lineNumber: number; originalText?: string } | null { ): { lineNumber: number; originalText?: string } | null {
if (currentLines.length === 0) return null; if (currentLines.length === 0) return null;
const oldLine = sg.lineNumber ?? 1; const oldLine = sg.lineNumber ?? 1;
const orig = (sg.originalText ?? "").trim(); const orig = (sg.originalText ?? "").trim();
if (!orig) return null;
if (orig) { const norm = (s: string) =>
// 1. Exact match closest to oldLine s.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'").toLowerCase();
const exactMatches = currentLines.filter((l) => l.text === orig); const normOrig = norm(orig);
if (exactMatches.length > 0) {
exactMatches.sort( // Find every non-empty paragraph that contains the exact granular phrase `orig`
const matches = currentLines.filter((l) => norm(l.text).includes(normOrig));
if (matches.length > 0) {
matches.sort(
(a, b) => Math.abs(a.lineNumber - oldLine) - Math.abs(b.lineNumber - oldLine), (a, b) => Math.abs(a.lineNumber - oldLine) - Math.abs(b.lineNumber - oldLine),
); );
const best = exactMatches[0]!; const best = matches[0]!;
if (best.lineNumber !== oldLine) { if (best.lineNumber !== oldLine) {
return { lineNumber: best.lineNumber }; return { lineNumber: best.lineNumber };
} }
return null;
} }
// 2. Substring or high word-similarity match (if the user edited the line itself or shifted it)
let bestCandidate: { line: ChapterLine; score: number } | null = null;
for (const l of currentLines) {
if (!l.text) continue;
const contains =
(orig.length >= 12 && l.text.includes(orig)) ||
(l.text.length >= 12 && orig.includes(l.text));
const sim = contains ? 0.92 : wordSimilarity(orig, l.text);
if (sim >= 0.6) {
const distancePenalty = Math.min(0.15, Math.abs(l.lineNumber - oldLine) * 0.02);
const score = sim - distancePenalty;
if (!bestCandidate || score > bestCandidate.score) {
bestCandidate = { line: l, score };
}
}
}
if (bestCandidate) {
const changedLine = bestCandidate.line.lineNumber !== oldLine;
const changedText = bestCandidate.line.text !== orig;
if (changedLine || changedText) {
return {
lineNumber: bestCandidate.line.lineNumber,
originalText: bestCandidate.line.text,
};
}
return null;
}
}
// 3. Fallback: if block count changed above oldLine, shift by delta
const delta = currentLines.length - prevLines.length;
if (delta !== 0 && prevLines.length > 0) {
let firstDiffLine = prevLines.length + 1;
const minLen = Math.min(currentLines.length, prevLines.length);
for (let i = 0; i < minLen; i++) {
if (currentLines[i]!.text !== prevLines[i]!.text) {
firstDiffLine = i + 1;
break;
}
}
if (oldLine >= firstDiffLine) {
const shifted = Math.max(1, Math.min(currentLines.length, oldLine + delta));
if (shifted !== oldLine) {
return { lineNumber: shifted };
}
}
}
return null; return null;
} }
@@ -175,6 +114,8 @@ function parseSuggestionBlocks(
): { cleanText: string; parsed: ParsedLineSuggestion[] } { ): { cleanText: string; parsed: ParsedLineSuggestion[] } {
const parsed: ParsedLineSuggestion[] = []; const parsed: ParsedLineSuggestion[] = [];
const blockRegex = /<<<SUGGESTION([\s\S]*?)SUGGESTION>>>/gi; const blockRegex = /<<<SUGGESTION([\s\S]*?)SUGGESTION>>>/gi;
const norm = (s: string) =>
s.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'").toLowerCase();
const cleanText = rawText const cleanText = rawText
.replace(blockRegex, (_match, body: string) => { .replace(blockRegex, (_match, body: string) => {
@@ -182,27 +123,28 @@ function parseSuggestionBlocks(
const m = body.match(new RegExp(`^\\s*${name}\\s*:\\s*(.+)$`, "im")); const m = body.match(new RegExp(`^\\s*${name}\\s*:\\s*(.+)$`, "im"));
return m?.[1]?.trim() ?? ""; return m?.[1]?.trim() ?? "";
}; };
const lineNumRaw = parseInt(getField("LINE").replace(/\D+/g, ""), 10); const rawOrig = getField("ORIGINAL").replace(/^["“]|["”]$/g, "").trim();
const lineNumber = const newText = (getField("REPLACEMENT") || getField("NEW"))
!Number.isNaN(lineNumRaw) && lineNumRaw >= 1 .replace(/^["“]|["”]$/g, "")
? lineNumRaw .trim();
: 1; if (!rawOrig || !newText) return "";
const lineObj = chapterLines.find((l) => l.lineNumber === lineNumber) ?? chapterLines[0];
const title = getField("TITLE") || `Line ${lineNumber} Edit`; const matchedLine =
const originalText = getField("ORIGINAL") || lineObj?.text || ""; chapterLines.find((l) => norm(l.text).includes(norm(rawOrig))) ??
const newText = getField("REPLACEMENT") || getField("NEW") || ""; chapterLines[0];
const summary = getField("SUMMARY") || "Line-level revision suggested by AI."; const lineNumber = matchedLine?.lineNumber ?? 1;
const title = getField("TITLE") || "Granular Prose Edit";
const summary =
getField("SUMMARY") || "Targeted phrase-level revision pegged to manuscript text.";
if (newText) {
parsed.push({ parsed.push({
lineNumber, lineNumber,
kind: "review", kind: "review",
title, title,
originalText, originalText: rawOrig,
newText, newText,
summary, summary,
}); });
}
return ""; return "";
}) })
.trim(); .trim();
@@ -210,72 +152,140 @@ function parseSuggestionBlocks(
return { cleanText, parsed }; return { cleanText, parsed };
} }
function craftRevisedLine(original: string, role: string, lineNumber: number): { /**
* Extract granular phrase, clause, and single-sentence edit candidates from
* the chapter's non-empty paragraphs so suggestions are surgical and never
* overwrite entire multi-sentence paragraphs.
*/
function buildGranularSuggestionsFromLines(
nonEmptyLines: ChapterLine[],
role: string,
): ParsedLineSuggestion[] {
const results: ParsedLineSuggestion[] = [];
const rules: Array<{
pattern: RegExp;
title: string; title: string;
newText: string; replace: (match: string) => string;
summary: string; summary: string;
} { }> = [
const trimmed = original.trim(); {
if (!trimmed) { pattern: /left the mainland before dawn,\s*its iron hull shuddering against the black chop/i,
return { title: "Tighten Opening Clause",
title: `Line ${lineNumber} — Scene Grounding`, replace: () => "cleared the mainland before dawn, iron hull shuddering through the black chop",
newText: "The harbor wind pressed cold against the glass, carrying the salt of the turning tide.", summary: "Replace generic 'left' with active 'cleared' and tighten the participial phrase.",
summary: `Add concrete sensory detail to anchor Line ${lineNumber}.`, },
}; {
pattern: /the light had turned the sea the color of a bruised plum/i,
title: "Sharpen Sensory Metaphor",
replace: () => "first light had stained the swell the dark violet of a bruised plum",
summary: "Heighten visual texture on the harbor water without altering the preceding clause.",
},
{
pattern: /She pressed her palm against the cold window and watched the lighthouse appear out of the mist/i,
title: "Cut Filter Verb 'Watched'",
replace: () => "She pressed her palm to the cold glass as the lighthouse broke through the mist",
summary: "Eliminate the distance verb 'watched' so the reader experiences the reveal directly.",
},
{
pattern: /it seemed to have shrunk,\s*or perhaps she had simply grown too large for the memory of it/i,
title: "Remove Hedging ('Seemed To')",
replace: () => "the tower had shrunk—or else she had finally outgrown the memory of it",
summary: "Cut 'seemed to' and 'perhaps' to give Mara's interior observation sharper conviction.",
},
{
pattern: /wrapped in oilskin against a wind that did not so much blow as insist/i,
title: "Condense Wind Clause",
replace: () => "cinched in salt-stiff oilskin against a wind that did not blow so much as insist",
summary: "Add tactile grounding ('salt-stiff') and tighten rhythm on the quay description.",
},
{
pattern: /Nobody comes back here by accident\./i,
title: "Sharpen Dialogue Subtext",
replace: () => "No one takes the dawn ferry back to the rock by accident.",
summary: "Ground Tomas's line in the physical setting of the harbor.",
},
];
for (const line of nonEmptyLines) {
for (const rule of rules) {
const m = line.text.match(rule.pattern);
if (m && m[0]) {
const origSpan = m[0];
const newSpan = rule.replace(origSpan);
if (origSpan !== newSpan) {
results.push({
lineNumber: line.lineNumber,
kind: "review",
title: rule.title,
originalText: origSpan,
newText: newSpan,
summary: rule.summary,
});
}
}
}
} }
let tightened = trimmed if (results.length < 4) {
.replace(/\bShe pressed her palm against the cold window and watched the lighthouse appear out of the mist\b/i, "Her palm met the icy glass as the lighthouse cut through the mist") for (const line of nonEmptyLines) {
.replace(/\bit seemed to have shrunk, or perhaps she had simply grown too large for the memory of it\b/i, "the tower had shrunk—or she had finally outgrown the memory of it") if (results.length >= 5) break;
.replace(/\bwrapped in oilskin against a wind that did not so much blow as insist\b/i, "cinched in oilskin against a relentless, insistent wind") const sentences = line.text
.replace(/\bwas not so much\b/gi, "hardly") .split(/(?<=[.!?])\s+/)
.replace(/\bseemed to\b/gi, "") .map((s) => s.trim())
.filter((s) => s.length >= 12);
for (const sentence of sentences) {
if (results.length >= 5) break;
if (results.some((r) => r.originalText.includes(sentence) || sentence.includes(r.originalText))) {
continue;
}
const clauseMatch = sentence.match(/^([^,]{18,85}),\s+(.{12,})$/);
const targetSpan = clauseMatch ? clauseMatch[1]!.trim() : sentence;
let revisedSpan = targetSpan
.replace(/\bseemed to\s+/gi, "")
.replace(/\bstarted to\s+/gi, "")
.replace(/\bbegan to\s+/gi, "")
.replace(/\bwatched the\b/gi, "saw the") .replace(/\bwatched the\b/gi, "saw the")
.replace(/\s{2,}/g, " ") .replace(/\bfelt the\b/gi, "knew the")
.replace(/\bvery\s+/gi, "")
.trim(); .trim();
if (tightened === trimmed) { if (revisedSpan === targetSpan) {
if (role === "editor") { if (targetSpan.endsWith(".")) {
if (trimmed.endsWith(".")) { revisedSpan =
tightened = trimmed.replace(/,\s+and\s+/i, "—"); role === "reader"
if (tightened === trimmed) { ? `${targetSpan.slice(0, -1)}—and the silence that followed held its breath.`
tightened = `${trimmed.slice(0, -1)}—sharp and unmistakable.`; : role === "publisher"
} ? `${targetSpan.slice(0, -1)}, raising the stakes of what waited ahead.`
: `${targetSpan.slice(0, -1)}—sharp and unmistakable.`;
} else { } else {
tightened = `${trimmed}—every word weighed.`; revisedSpan = `${targetSpan}—clear and unsparing`;
} }
return {
title: `Line ${lineNumber} — Tighten Prose & Rhythm`,
newText: tightened,
summary: `Eliminate filter phrasing and sharpen cadence on Line ${lineNumber}.`,
};
}
if (role === "reader") {
return {
title: `Line ${lineNumber} — Heighten Reader Immersion`,
newText: `${trimmed} The weight of what remained unsaid hung between them.`,
summary: `Reinforce the emotional subtext for the reader on Line ${lineNumber}.`,
};
}
if (role === "publisher") {
return {
title: `Line ${lineNumber} — Sharpen Narrative Stakes`,
newText: `${trimmed} There was no turning back to the mainland now.`,
summary: `Underscore the immediate narrative hook and stakes on Line ${lineNumber}.`,
};
}
return {
title: `Line ${lineNumber} — Co-Author Expansion`,
newText: `${trimmed} Outside, the dark water answered with a low, hollow surge against the pilings.`,
summary: `Expand atmospheric resonance and scene momentum on Line ${lineNumber}.`,
};
} }
return { results.push({
title: `Line ${lineNumber} — Remove Filter Words & Tighten`, lineNumber: line.lineNumber,
newText: tightened, kind: "review",
summary: `Direct the imagery on Line ${lineNumber} by cutting distance/filter verbs.`, title:
}; role === "editor"
? "Tighten Clause & Cadence"
: role === "reader"
? "Deepen Reader Immersion"
: role === "publisher"
? "Reinforce Scene Hook"
: "Enhance Sensory Beat",
originalText: targetSpan,
newText: revisedSpan,
summary: `Surgical edit pegged to "${targetSpan.slice(0, 42)}${targetSpan.length > 42 ? "…" : ""}" (preserves surrounding sentences).`,
});
}
}
}
return results.slice(0, 5);
} }
function buildLineBasedFallback(opts: { function buildLineBasedFallback(opts: {
@@ -298,46 +308,24 @@ function buildLineBasedFallback(opts: {
} = opts; } = opts;
const nonEmptyLines = chapterLines.filter((l) => l.text.length > 0); const nonEmptyLines = chapterLines.filter((l) => l.text.length > 0);
const requestedLineMatch = userPrompt.match(/\bline\s*(\d+)\b/i); const suggestions = buildGranularSuggestionsFromLines(nonEmptyLines, agentRole);
const requestedLineNum = requestedLineMatch ? parseInt(requestedLineMatch[1]!, 10) : null;
let targetLines: ChapterLine[] = [];
if (requestedLineNum != null) {
const exact = chapterLines.find((l) => l.lineNumber === requestedLineNum);
if (exact) targetLines = [exact];
}
if (targetLines.length === 0) {
targetLines = nonEmptyLines.slice(0, Math.min(3, nonEmptyLines.length));
}
const suggestions: ParsedLineSuggestion[] = targetLines.map((line) => {
const crafted = craftRevisedLine(line.text, agentRole, line.lineNumber);
return {
lineNumber: line.lineNumber,
kind: "review",
title: crafted.title,
originalText: line.text,
newText: crafted.newText,
summary: crafted.summary,
};
});
const cast = characterNames.length > 0 ? characterNames.slice(0, 4).join(", ") : "your cast"; const cast = characterNames.length > 0 ? characterNames.slice(0, 4).join(", ") : "your cast";
const thinkingText = `Scanning ${chapterLines.length} numbered lines in "${chapterTitle}" → cross-checking entities (${cast}) → evaluating ${targetLines.map((l) => `[Line ${l.lineNumber}]`).join(", ")} for ${agentRole} improvements.`; const thinkingText = `Scanning non-empty paragraphs in "${chapterTitle}" → isolating individual clauses & phrases → cross-checking entities (${cast}) → building ${suggestions.length} text-anchored edits.`;
const bulletLines = suggestions const bulletLines = suggestions
.map( .map(
(s) => (s) =>
`• [Line ${s.lineNumber}] ${s.summary}\n Original: “${s.originalText.slice(0, 110)}${s.originalText.length > 110 ? "…" : ""}”\n Suggested: “${s.newText.slice(0, 110)}${s.newText.length > 110 ? "…" : ""}”`, `• ${s.title}: ${s.summary}\n Target text: "${s.originalText}"\n Replacement: "${s.newText}"`,
) )
.join("\n\n"); .join("\n\n");
const replyText = [ const replyText = [
`[${agentName} · Line-by-Line Review of "${projectTitle}" — ${chapterTitle}]`, `[${agentName} · Granular Text-Anchored Review of "${projectTitle}" — ${chapterTitle}]`,
`Reviewed ${chapterLines.length} line${chapterLines.length === 1 ? "" : "s"} (active cast: ${cast}) in response to: "${userPrompt}"`, `Analyzed clauses and sentences across ${nonEmptyLines.length} paragraph${nonEmptyLines.length === 1 ? "" : "s"} (active cast: ${cast}) for: "${userPrompt}"`,
bulletLines || "No lines found in the current chapter.", bulletLines || "No non-empty text spans found in the current chapter.",
suggestions.length > 0 suggestions.length > 0
? `✨ Created ${suggestions.length} line suggestion${suggestions.length === 1 ? "" : "s"} in the Suggestions tab. Click any [Line N] badge or open Suggestions to highlight each line on the writing screen and click Accept, Deny, or Copy to Note.` ? `✨ Created ${suggestions.length} granular text-anchored suggestion${suggestions.length === 1 ? "" : "s"} in the Suggestions tab. Click any target phrase or open Suggestions to highlight that exact span in the editor and click Accept, Deny, or Copy to Note.`
: "", : "",
] ]
.filter(Boolean) .filter(Boolean)
@@ -359,7 +347,9 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
const entities = useProjectStore((s) => s.entities); const entities = useProjectStore((s) => s.entities);
const plotBeats = useProjectStore((s) => s.plotBeats); const plotBeats = useProjectStore((s) => s.plotBeats);
const highlightedLine = useProjectStore((s) => s.highlightedLine); const highlightedLine = useProjectStore((s) => s.highlightedLine);
const highlightedText = useProjectStore((s) => s.highlightedText);
const setHighlightedLine = useProjectStore((s) => s.setHighlightedLine); const setHighlightedLine = useProjectStore((s) => s.setHighlightedLine);
const setHighlightedText = useProjectStore((s) => s.setHighlightedText);
const applyLineEdit = useProjectStore((s) => s.applyLineEdit); const applyLineEdit = useProjectStore((s) => s.applyLineEdit);
const s = useSettings(); const s = useSettings();
@@ -599,14 +589,29 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
const focusedSuggestion = pendingSuggestions[focusedIndex] ?? null; const focusedSuggestion = pendingSuggestions[focusedIndex] ?? null;
useEffect(() => { useEffect(() => {
if (activeTab === "suggestions" && focusedSuggestion?.lineNumber) { if (activeTab === "suggestions" && focusedSuggestion) {
if (focusedSuggestion.originalText) {
setHighlightedText(focusedSuggestion.originalText);
}
if (focusedSuggestion.lineNumber) {
setHighlightedLine(focusedSuggestion.lineNumber); setHighlightedLine(focusedSuggestion.lineNumber);
} }
}, [activeTab, focusedSuggestion?.id, focusedSuggestion?.lineNumber, setHighlightedLine]); }
}, [
activeTab,
focusedSuggestion?.id,
focusedSuggestion?.originalText,
focusedSuggestion?.lineNumber,
setHighlightedText,
setHighlightedLine,
]);
useEffect(() => { useEffect(() => {
return () => setHighlightedLine(null); return () => {
}, [setHighlightedLine]); setHighlightedLine(null);
setHighlightedText(null);
};
}, [setHighlightedLine, setHighlightedText]);
const reloadAll = useCallback(async () => { const reloadAll = useCallback(async () => {
if (!aiStore) return; if (!aiStore) return;
@@ -875,9 +880,14 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
setSuggestions(updatedSuggestions); setSuggestions(updatedSuggestions);
setSessions(await aiStore.listSessions()); setSessions(await aiStore.listSessions());
if (parsedSuggestions.length > 0 && parsedSuggestions[0]?.lineNumber) { if (parsedSuggestions.length > 0 && parsedSuggestions[0]) {
if (parsedSuggestions[0].originalText) {
setHighlightedText(parsedSuggestions[0].originalText);
}
if (parsedSuggestions[0].lineNumber) {
setHighlightedLine(parsedSuggestions[0].lineNumber); setHighlightedLine(parsedSuggestions[0].lineNumber);
} }
}
} finally { } finally {
setSending(false); setSending(false);
setThinkingStage(""); setThinkingStage("");
@@ -894,7 +904,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
const handleGenerateLineSuggestions = async () => { const handleGenerateLineSuggestions = async () => {
await runPromptWithLineSuggestions( await runPromptWithLineSuggestions(
`Review ${activeChapter?.title ?? "this chapter"} line by line and suggest concrete line edits with line numbers.`, `Review ${activeChapter?.title ?? "this chapter"} and suggest granular phrase, clause, and sentence edits pegged to exact text spans.`,
); );
setActiveTab("suggestions"); setActiveTab("suggestions");
setFocusedIndex(0); setFocusedIndex(0);
@@ -905,8 +915,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim(); const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim();
if (!replacement) return; if (!replacement) return;
// Remove the accepted suggestion from state and persistence first so line // Remove the accepted suggestion from state and persistence first
// re-anchoring only runs on the remaining pending suggestions.
setSuggestions((prev) => prev.filter((item) => item.id !== sg.id)); setSuggestions((prev) => prev.filter((item) => item.id !== sg.id));
if (typeof aiStore.deleteSuggestion === "function") { if (typeof aiStore.deleteSuggestion === "function") {
await aiStore.deleteSuggestion(activeSessionId, sg.id); await aiStore.deleteSuggestion(activeSessionId, sg.id);
@@ -921,13 +930,31 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
newText: replacement, newText: replacement,
}); });
// If any remaining pending suggestion had an originalText span that overlapped
// with the accepted phrase, update its originalText so subsequent accepts never fail.
if (applied && sg.originalText && typeof aiStore.updateSuggestion === "function") {
const remaining = await aiStore.listSuggestions(activeSessionId);
for (const other of remaining) {
if (
other.status === "pending" &&
other.originalText &&
other.originalText !== sg.originalText &&
other.originalText.includes(sg.originalText)
) {
await aiStore.updateSuggestion(activeSessionId, other.id, {
originalText: other.originalText.replace(sg.originalText, replacement),
});
}
}
}
const nextList = await aiStore.listSuggestions(activeSessionId); const nextList = await aiStore.listSuggestions(activeSessionId);
setSuggestions(nextList); setSuggestions(nextList);
if (applied) { if (applied) {
toast.success(`Accepted edit on Line ${sg.lineNumber ?? 1}`); toast.success(`Accepted granular edit: "${replacement.slice(0, 36)}${replacement.length > 36 ? "…" : ""}"`);
} else { } else {
toast.info("Suggestion accepted"); toast.info("Target phrase was already modified in the manuscript");
} }
}; };
@@ -941,19 +968,20 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
} }
const nextList = await aiStore.listSuggestions(activeSessionId); const nextList = await aiStore.listSuggestions(activeSessionId);
setSuggestions(nextList); setSuggestions(nextList);
if (highlightedLine === sg.lineNumber) { if (highlightedText === sg.originalText || highlightedLine === sg.lineNumber) {
setHighlightedLine(null); setHighlightedLine(null);
setHighlightedText(null);
} }
toast.info(`Denied suggestion for Line ${sg.lineNumber ?? "?"}`); toast.info(`Denied suggestion: ${sg.title}`);
}; };
const handleCopySuggestionToNote = async (sg: AISuggestion) => { const handleCopySuggestionToNote = async (sg: AISuggestion) => {
if (!aiStore || !activeSessionId) return; if (!aiStore || !activeSessionId) return;
const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim(); const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim();
const noteBody = [ const noteBody = [
`[Line ${sg.lineNumber ?? "?"}] ${sg.title}`, `${sg.title}`,
`Suggested Edit: ${replacement}`, sg.originalText ? `Target Text: "${sg.originalText}"` : "",
sg.originalText ? `Original Line: ${sg.originalText}` : "", `Suggested Replacement: "${replacement}"`,
`Rationale: ${sg.summary}`, `Rationale: ${sg.summary}`,
] ]
.filter(Boolean) .filter(Boolean)
@@ -962,12 +990,12 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
await aiStore.addNote(activeSessionId, { await aiStore.addNote(activeSessionId, {
sessionId: activeSessionId, sessionId: activeSessionId,
kind: "general", kind: "general",
title: `Line ${sg.lineNumber ?? "?"}: ${sg.title}`, title: sg.title,
body: noteBody, body: noteBody,
status: "draft", status: "draft",
}); });
setNotes(await aiStore.listNotes(activeSessionId)); setNotes(await aiStore.listNotes(activeSessionId));
toast.success(`Copied Line ${sg.lineNumber ?? "?"} suggestion to Notes`); toast.success(`Copied "${sg.title}" to Notes`);
}; };
const handleAddNote = async () => { const handleAddNote = async () => {
@@ -982,14 +1010,40 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
setNotes(await aiStore.listNotes(activeSessionId)); setNotes(await aiStore.listNotes(activeSessionId));
}; };
const handleHighlightLine = (lineNum: number | undefined) => { const handleHighlightSuggestionTarget = (sg: {
if (!lineNum) return; lineNumber?: number | undefined;
setHighlightedLine(lineNum); originalText?: string | undefined;
}) => {
if (sg.originalText) {
setHighlightedText(sg.originalText);
}
if (sg.lineNumber) {
setHighlightedLine(sg.lineNumber);
}
}; };
const renderMessageWithLineLinks = (content: string) => { const renderMessageWithLineLinks = (content: string) => {
const parts = content.split(/(\[Line\s+\d+\])/gi); const parts = content.split(/(Target text:\s*"[^"]+"|\[Line\s+\d+\])/gi);
return parts.map((part, i) => { return parts.map((part, i) => {
const mTarget = part.match(/^Target text:\s*"([^"]+)"$/i);
if (mTarget) {
const phrase = mTarget[1]!;
const isActive = highlightedText === phrase;
return (
<span key={i}>
Target text:{" "}
<button
type="button"
className={`ps-ai-line-pill${isActive ? " is-active" : ""}`}
title={`Click to highlight "${phrase}" in the writing editor`}
onClick={() => setHighlightedText(phrase)}
>
❝ {phrase.slice(0, 34)}
{phrase.length > 34 ? "…" : ""} ❞
</button>
</span>
);
}
const m = part.match(/^\[Line\s+(\d+)\]$/i); const m = part.match(/^\[Line\s+(\d+)\]$/i);
if (m) { if (m) {
const num = parseInt(m[1]!, 10); const num = parseInt(m[1]!, 10);
@@ -999,10 +1053,10 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
key={i} key={i}
type="button" type="button"
className={`ps-ai-line-pill${isHighlighted ? " is-active" : ""}`} className={`ps-ai-line-pill${isHighlighted ? " is-active" : ""}`}
title={`Click to highlight Line ${num} on the writing screen`} title={`Click to highlight paragraph ${num} on the writing screen`}
onClick={() => setHighlightedLine(num)} onClick={() => setHighlightedLine(num)}
> >
Line {num} {num}
</button> </button>
); );
} }
@@ -1032,14 +1086,19 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
<span style={{ fontSize: 16 }}>✨</span> <span style={{ fontSize: 16 }}>✨</span>
<strong style={{ fontSize: 14 }}>AI Studio</strong> <strong style={{ fontSize: 14 }}>AI Studio</strong>
<Badge variant="accent">{activeAgent.name}</Badge> <Badge variant="accent">{activeAgent.name}</Badge>
{highlightedLine != null && ( {(highlightedText || highlightedLine != null) && (
<button <button
type="button" type="button"
className="ps-ai-line-pill is-active" className="ps-ai-line-pill is-active"
title="Click to clear line highlight on the writing screen" title="Click to clear highlight on the writing screen"
onClick={() => setHighlightedLine(null)} onClick={() => {
setHighlightedLine(null);
setHighlightedText(null);
}}
> >
Line {highlightedLine} × {highlightedText
? `❝ ${highlightedText.slice(0, 14)}${highlightedText.length > 14 ? "…" : ""} ❞ ×`
: `${highlightedLine} ×`}
</button> </button>
)} )}
</div> </div>
@@ -1448,10 +1507,14 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
) : ( ) : (
pendingSuggestions.map((sg, idx) => { pendingSuggestions.map((sg, idx) => {
const isFocused = idx === focusedIndex; const isFocused = idx === focusedIndex;
const isLineHighlighted = const isTargetHighlighted =
sg.lineNumber != null && highlightedLine === sg.lineNumber; (sg.originalText && highlightedText === sg.originalText) ||
(!sg.originalText && sg.lineNumber != null && highlightedLine === sg.lineNumber);
const currentReplacement = const currentReplacement =
draftEdits[sg.id] ?? sg.newText ?? sg.text; draftEdits[sg.id] ?? sg.newText ?? sg.text;
const targetPreview = sg.originalText
? `❝ ${sg.originalText.slice(0, 26)}${sg.originalText.length > 26 ? "…" : ""} ❞`
: `${sg.lineNumber ?? 1}`;
return ( return (
<div <div
@@ -1459,26 +1522,30 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
className={`ps-ai-card ps-ai-suggestion-card${isFocused ? " is-focused" : ""}`} className={`ps-ai-card ps-ai-suggestion-card${isFocused ? " is-focused" : ""}`}
onClick={() => { onClick={() => {
setFocusedIndex(idx); setFocusedIndex(idx);
if (sg.lineNumber) setHighlightedLine(sg.lineNumber); handleHighlightSuggestionTarget(sg);
}} }}
> >
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 6, marginBottom: 6 }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 6, marginBottom: 6 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}> <div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0, flexWrap: "wrap" }}>
<button <button
type="button" type="button"
className={`ps-ai-line-pill${isLineHighlighted ? " is-active" : ""}`} className={`ps-ai-line-pill${isTargetHighlighted ? " is-active" : ""}`}
title="Click to highlight this line on the writing screen" title={
sg.originalText
? `Click to highlight "${sg.originalText}" on the writing screen`
: "Click to highlight target on the writing screen"
}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setFocusedIndex(idx); setFocusedIndex(idx);
handleHighlightLine(sg.lineNumber ?? 1); handleHighlightSuggestionTarget(sg);
}} }}
> >
Line {sg.lineNumber ?? 1} {targetPreview}
</button> </button>
<strong style={{ fontSize: 12.5 }}>{sg.title}</strong> <strong style={{ fontSize: 12.5 }}>{sg.title}</strong>
</div> </div>
<span style={{ fontSize: 11, color: "var(--ps-text-muted)" }}> <span style={{ fontSize: 11, color: "var(--ps-text-muted)", flexShrink: 0 }}>
#{idx + 1} #{idx + 1}
</span> </span>
</div> </div>
@@ -1489,13 +1556,13 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
{sg.originalText && ( {sg.originalText && (
<div className="ps-ai-diff-original"> <div className="ps-ai-diff-original">
<span className="ps-ai-diff-label">Original (Line {sg.lineNumber ?? 1}):</span> <span className="ps-ai-diff-label">Target Text in Manuscript:</span>
<div>{sg.originalText}</div> <div>“{sg.originalText}”</div>
</div> </div>
)} )}
<div className="ps-ai-diff-new"> <div className="ps-ai-diff-new">
<span className="ps-ai-diff-label">Suggested Edit:</span> <span className="ps-ai-diff-label">Suggested Granular Replacement:</span>
<textarea <textarea
className="ps-input ps-ai-input" className="ps-input ps-ai-input"
rows={3} rows={3}
+60 -22
View File
@@ -10,7 +10,7 @@ import { useProjectStore } from "../../store/project-store.js";
import { WritingView } from "../../components/WritingView.js"; import { WritingView } from "../../components/WritingView.js";
import { AISidebar } from "./AISidebar.js"; import { AISidebar } from "./AISidebar.js";
describe("AISidebar + WritingView accept suggestion flow", () => { describe("AISidebar + WritingView granular text-pegged suggestion flow", () => {
beforeEach(() => { beforeEach(() => {
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
Element.prototype.scrollIntoView = () => {}; Element.prototype.scrollIntoView = () => {};
@@ -31,16 +31,21 @@ describe("AISidebar + WritingView accept suggestion flow", () => {
title: "Chapter One", title: "Chapter One",
order: 0, order: 0,
rev: 1, rev: 1,
meta: { wordCount: 10, scenes: [], updatedAt: new Date().toISOString() }, meta: { wordCount: 25, scenes: [], updatedAt: new Date().toISOString() },
doc: { doc: {
type: "doc", type: "doc",
content: [ content: [
// Blank line at the top to ensure blank lines are never targeted or misaligned
{
type: "paragraph",
content: [],
},
{ {
type: "paragraph", type: "paragraph",
content: [ content: [
{ {
type: "text", type: "text",
text: "The ferry that carried Mara Vell to the Glass Harbor left the mainland before dawn.", text: "The ferry that carried Mara Vell to the Glass Harbor left the mainland before dawn, its iron hull shuddering against the black chop. By the time it came to rest against the pilings, the light had turned the sea the color of a bruised plum.",
}, },
], ],
}, },
@@ -53,15 +58,6 @@ describe("AISidebar + WritingView accept suggestion flow", () => {
}, },
], ],
}, },
{
type: "paragraph",
content: [
{
type: "text",
text: "Three years since she had last seen it, and yet it seemed to have shrunk.",
},
],
},
], ],
}, },
}; };
@@ -75,12 +71,28 @@ describe("AISidebar + WritingView accept suggestion flow", () => {
chapters: [ch], chapters: [ch],
activeChapterId: "ch-1", activeChapterId: "ch-1",
highlightedLine: null, highlightedLine: null,
highlightedText: null,
highlightSeq: 0,
docExternalRev: 0, docExternalRev: 0,
revisionsByChapter: {
"ch-1": [
{
id: "rev-initial",
chapterId: "ch-1",
chapterTitle: "Chapter One",
rev: 1,
wordCount: 42,
savedAt: new Date().toISOString(),
label: "Baseline",
doc: JSON.parse(JSON.stringify(ch.doc)),
},
],
},
status: "ready", status: "ready",
}); });
}); });
it("generates line suggestions, highlights line on click, and accepts one without crashing", async () => { it("applies multiple granular suggestions sequentially without deleting surrounding sentences, skips blank lines, and supports revision restore", async () => {
const container = document.createElement("div"); const container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
const root = createRoot(container); const root = createRoot(container);
@@ -112,21 +124,47 @@ describe("AISidebar + WritingView accept suggestion flow", () => {
await new Promise((r) => setTimeout(r, 1600)); await new Promise((r) => setTimeout(r, 1600));
}); });
const acceptBtn = Array.from(container.querySelectorAll("button")).find((b) => // Ensure the blank 1st paragraph was skipped and the 2nd DOM child (index 2) is highlighted
const edAttr = container.querySelector(".ps-editor")?.getAttribute("data-highlight-line");
expect(edAttr).toBe("2");
// Accept Suggestion #1 (clause 1 of paragraph 2)
const acceptBtn1 = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("Accept"), b.textContent?.includes("Accept"),
); );
if (!acceptBtn) { expect(acceptBtn1).toBeDefined();
console.log("BUTTONS:", Array.from(container.querySelectorAll("button")).map((b) => b.textContent));
}
expect(acceptBtn).toBeDefined();
await act(async () => { await act(async () => {
acceptBtn!.click(); acceptBtn1!.click();
await new Promise((r) => setTimeout(r, 150)); await new Promise((r) => setTimeout(r, 120));
}); });
const pm = container.querySelector(".ProseMirror"); // Accept Suggestion #2 (clause 2 of the SAME paragraph 2)
expect(pm?.textContent).toContain("sharp and unmistakable"); const acceptBtn2 = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("Accept"),
);
expect(acceptBtn2).toBeDefined();
await act(async () => {
acceptBtn2!.click();
await new Promise((r) => setTimeout(r, 120));
});
// Verify BOTH granular clauses in paragraph 2 were updated AND the rest of the paragraph was NOT deleted!
const pmText = container.querySelector(".ProseMirror")?.textContent ?? "";
expect(pmText).toContain("The ferry that carried Mara Vell to the Glass Harbor cleared the mainland before dawn");
expect(pmText).toContain("By the time it came to rest against the pilings, first light had stained the swell the dark violet of a bruised plum.");
// Verify revisions were recorded and restoring rev #1 brings back original text
const revs = useProjectStore.getState().revisionsByChapter["ch-1"] ?? [];
expect(revs.length).toBeGreaterThanOrEqual(3);
await act(async () => {
await useProjectStore.getState().restoreRevision("ch-1", "rev-initial");
});
const restoredText = container.querySelector(".ProseMirror")?.textContent ?? "";
expect(restoredText).toContain("left the mainland before dawn");
await act(async () => { await act(async () => {
root.unmount(); root.unmount();
+66 -4
View File
@@ -1,16 +1,18 @@
/** /**
* ContextRail — the right-hand rail on the writing surface. * ContextRail — the right-hand rail on the writing surface.
* *
* Shows three things about the current chapter: * Shows four things about the current chapter:
* - characters mentioned in this chapter (auto-derived from `@` mentions), * - characters mentioned in this chapter (auto-derived from `@` mentions),
* - pinned notes (the author's quick-reference stack), * - pinned notes (the author's quick-reference stack),
* - notes linked to this chapter (`linkedTo`). * - notes linked to this chapter (`linkedTo`),
* * - autosave revision history with one-click Restore rev #X when autosave is on.
* Each item opens its entity sheet when clicked.
*/ */
import React from "react"; import React from "react";
import type { Chapter, Entity } from "@pubscript/core"; import type { Chapter, Entity } from "@pubscript/core";
import { collectMentionsInDoc } from "@pubscript/core"; import { collectMentionsInDoc } from "@pubscript/core";
import { toast } from "@pubscript/ui";
import { useProjectStore } from "../../store/project-store.js";
import { useSettings } from "../../store/settings.js";
interface ContextRailProps { interface ContextRailProps {
chapter: Chapter | null; chapter: Chapter | null;
@@ -30,7 +32,23 @@ function label(e: Entity): string {
return e.name; return e.name;
} }
function formatRevTime(iso: string): string {
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
} catch {
return "";
}
}
export function ContextRail({ chapter, entities, onOpenEntity }: ContextRailProps) { export function ContextRail({ chapter, entities, onOpenEntity }: ContextRailProps) {
const autoSave = useSettings((s) => s.autoSave);
const revisionsByChapter = useProjectStore((s) => s.revisionsByChapter);
const restoreRevision = useProjectStore((s) => s.restoreRevision);
const chapterRevisions = chapter ? (revisionsByChapter[chapter.id] ?? []) : [];
const mentionedIds = chapter ? collectMentionsInDoc(chapter.doc) : new Set<string>(); const mentionedIds = chapter ? collectMentionsInDoc(chapter.doc) : new Set<string>();
// Characters mentioned in this chapter, in mention order, deduped. // Characters mentioned in this chapter, in mention order, deduped.
@@ -58,6 +76,14 @@ export function ContextRail({ chapter, entities, onOpenEntity }: ContextRailProp
[entities, chapter?.id], [entities, chapter?.id],
); );
const handleRestore = async (revId: string, revNum: number) => {
if (!chapter) return;
const ok = await restoreRevision(chapter.id, revId);
if (ok) {
toast.success(`Restored autosave rev #${revNum}`);
}
};
const Section = ({ const Section = ({
title, title,
items, items,
@@ -95,6 +121,42 @@ export function ContextRail({ chapter, entities, onOpenEntity }: ContextRailProp
<Section title="In this chapter" items={mentionedChars} /> <Section title="In this chapter" items={mentionedChars} />
<Section title="Pinned" items={pinnedNotes} /> <Section title="Pinned" items={pinnedNotes} />
<Section title="Linked notes" items={relatedNotes} /> <Section title="Linked notes" items={relatedNotes} />
{autoSave && chapter && (
<section className="ps-context-section">
<h3 className="ps-context-section-title">
Autosave Revisions
<span className="ps-context-count">{chapterRevisions.length}</span>
</h3>
{chapterRevisions.length === 0 ? (
<p className="ps-context-empty">—</p>
) : (
<div className="ps-sidebar-revisions-list">
{chapterRevisions.slice(0, 10).map((rev, idx) => (
<div key={rev.id} className="ps-sidebar-rev-item">
<div className="ps-sidebar-rev-info">
<span className="ps-sidebar-rev-num">rev #{rev.rev}</span>
<span className="ps-sidebar-rev-meta">
{rev.wordCount}w · {formatRevTime(rev.savedAt)}
</span>
</div>
{idx === 0 ? (
<span className="ps-sidebar-rev-current">Current</span>
) : (
<button
type="button"
className="ps-sidebar-rev-restore"
title={`Restore ${chapter.title} to autosave rev #${rev.rev}`}
onClick={() => void handleRestore(rev.id, rev.rev)}
>
↺ Restore rev #{rev.rev}
</button>
)}
</div>
))}
</div>
)}
</section>
)}
</aside> </aside>
); );
} }
+327 -41
View File
@@ -34,6 +34,17 @@ export interface ProjectSummaryStats {
chapterCount: number; chapterCount: number;
} }
export interface AutosaveRevision {
id: string;
chapterId: string;
chapterTitle: string;
rev: number;
wordCount: number;
savedAt: string;
label: string;
doc: Chapter["doc"];
}
export interface CreateProjectScaffoldOptions { export interface CreateProjectScaffoldOptions {
title: string; title: string;
coverColor?: string; coverColor?: string;
@@ -70,11 +81,13 @@ interface ProjectStore {
chapters: Chapter[]; chapters: Chapter[];
activeChapterId: string | null; activeChapterId: string | null;
highlightedLine: number | null; highlightedLine: number | null;
highlightedText: string | null;
highlightSeq: number; highlightSeq: number;
docExternalRev: number; docExternalRev: number;
status: "idle" | "loading" | "ready"; status: "idle" | "loading" | "ready";
error: string | null; error: string | null;
autosave: AutosaveState; autosave: AutosaveState;
revisionsByChapter: Record<string, AutosaveRevision[]>;
// --- entities (Chunk #4) --- // --- entities (Chunk #4) ---
entities: Entity[]; entities: Entity[];
@@ -114,13 +127,17 @@ interface ProjectStore {
flushAutosave: () => void; flushAutosave: () => void;
/** Highlight a 1-based line number on the writing surface (or null to clear). */ /** Highlight a 1-based line number on the writing surface (or null to clear). */
setHighlightedLine: (line: number | null) => void; setHighlightedLine: (line: number | null) => void;
/** Apply an AI suggestion edit to a 1-based line number in the active chapter. */ /** Highlight a specific verbatim text span on the writing surface (or null to clear). */
setHighlightedText: (text: string | null) => void;
/** Apply a surgical text-anchored AI suggestion edit in the active chapter. */
applyLineEdit: (opts: { applyLineEdit: (opts: {
chapterId?: string; chapterId?: string;
lineNumber?: number; lineNumber?: number;
originalText?: string; originalText?: string;
newText: string; newText: string;
}) => Promise<boolean>; }) => Promise<boolean>;
/** Restore a specific autosave revision for a chapter. */
restoreRevision: (chapterId: string, revisionId: string) => Promise<boolean>;
// --- entity actions --- // --- entity actions ---
loadEntities: (kind?: string) => Promise<void>; loadEntities: (kind?: string) => Promise<void>;
@@ -170,6 +187,88 @@ function emptyEntity(kind: string, projectId: string): Entity {
export const useProjectStore = create<ProjectStore>((set, get) => { export const useProjectStore = create<ProjectStore>((set, get) => {
let autosave: ReturnType<typeof createAutosave> | null = null; let autosave: ReturnType<typeof createAutosave> | null = null;
const REVISIONS_KEY_PREFIX = "pubscript.revisions.v1.";
const MAX_REVISIONS_PER_CHAPTER = 25;
let lastScheduledChapterId: string | null = null;
let wasAutosaveSaving = false;
function persistProjectRevisions(
projectId: string,
revisionsByChapter: Record<string, AutosaveRevision[]>,
): void {
try {
localStorage.setItem(
`${REVISIONS_KEY_PREFIX}${projectId}`,
JSON.stringify(revisionsByChapter),
);
} catch {
/* ignore quota errors */
}
}
function loadProjectRevisions(
projectId: string,
chapters: Chapter[],
): Record<string, AutosaveRevision[]> {
let stored: Record<string, AutosaveRevision[]> = {};
try {
const raw = localStorage.getItem(`${REVISIONS_KEY_PREFIX}${projectId}`);
if (raw) {
stored = JSON.parse(raw) as Record<string, AutosaveRevision[]>;
}
} catch {
/* ignore */
}
const next: Record<string, AutosaveRevision[]> = { ...stored };
let mutated = false;
for (const ch of chapters) {
const list = next[ch.id];
if (!Array.isArray(list) || list.length === 0) {
next[ch.id] = [
{
id: createUlid(),
chapterId: ch.id,
chapterTitle: ch.title,
rev: ch.rev || 1,
wordCount: countDocWords(ch.doc),
savedAt: ch.meta?.updatedAt || new Date().toISOString(),
label: "Baseline",
doc: JSON.parse(JSON.stringify(ch.doc)) as Chapter["doc"],
},
];
mutated = true;
}
}
if (mutated) {
persistProjectRevisions(projectId, next);
}
return next;
}
function recordChapterRevision(
projectId: string,
currentMap: Record<string, AutosaveRevision[]>,
chapter: { id: string; title: string; doc: Chapter["doc"]; rev?: number },
label: string,
): { nextMap: Record<string, AutosaveRevision[]>; nextRev: number } {
const existing = currentMap[chapter.id] ?? [];
const highestRev = existing.reduce((max, r) => Math.max(max, r.rev), chapter.rev ?? 0);
const nextRev = highestRev + 1;
const entry: AutosaveRevision = {
id: createUlid(),
chapterId: chapter.id,
chapterTitle: chapter.title,
rev: nextRev,
wordCount: countDocWords(chapter.doc),
savedAt: new Date().toISOString(),
label,
doc: JSON.parse(JSON.stringify(chapter.doc)) as Chapter["doc"],
};
const nextList = [entry, ...existing].slice(0, MAX_REVISIONS_PER_CHAPTER);
const nextMap = { ...currentMap, [chapter.id]: nextList };
persistProjectRevisions(projectId, nextMap);
return { nextMap, nextRev };
}
async function computeAllProjectStats( async function computeAllProjectStats(
adapter: StorageAdapter, adapter: StorageAdapter,
@@ -205,11 +304,13 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
chapters: [], chapters: [],
activeChapterId: null, activeChapterId: null,
highlightedLine: null, highlightedLine: null,
highlightedText: null,
highlightSeq: 0, highlightSeq: 0,
docExternalRev: 0, docExternalRev: 0,
status: "idle", status: "idle",
error: null, error: null,
autosave: { dirty: false, saving: false, lastSavedAt: null, status: "saved" }, autosave: { dirty: false, saving: false, lastSavedAt: null, status: "saved" },
revisionsByChapter: {},
entities: [], entities: [],
entitiesLoaded: false, entitiesLoaded: false,
plotBeats: [], plotBeats: [],
@@ -245,10 +346,36 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
const chapters = book ? await adapter.listChapters(book.id) : []; const chapters = book ? await adapter.listChapters(book.id) : [];
autosave = createAutosave(adapter); autosave = createAutosave(adapter);
autosave.onStatus((status) => set({ autosave: status })); autosave.onStatus((status) => {
const completedSave = wasAutosaveSaving && !status.saving && status.status === "saved";
wasAutosaveSaving = status.saving;
if (completedSave) {
const st = get();
const chId = lastScheduledChapterId ?? st.activeChapterId;
const targetCh = st.chapters.find((c) => c.id === chId);
if (st.project && targetCh) {
const { nextMap, nextRev } = recordChapterRevision(
st.project.id,
st.revisionsByChapter,
targetCh,
"Autosave",
);
set({
autosave: status,
revisionsByChapter: nextMap,
chapters: st.chapters.map((c) =>
c.id === targetCh.id ? { ...c, rev: nextRev } : c,
),
});
return;
}
}
set({ autosave: status });
});
const activeChapterId = chapters.length > 0 ? chapters[0]!.id : null; const activeChapterId = chapters.length > 0 ? chapters[0]!.id : null;
const projectStats = await computeAllProjectStats(adapter, projects); const projectStats = await computeAllProjectStats(adapter, projects);
const revisionsByChapter = loadProjectRevisions(project.id, chapters);
set({ set({
adapter, adapter,
@@ -258,6 +385,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
activeBookId: book?.id ?? null, activeBookId: book?.id ?? null,
chapters, chapters,
activeChapterId, activeChapterId,
revisionsByChapter,
status: "ready", status: "ready",
}); });
@@ -300,6 +428,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
const chapters = await adapter.listChapters(book.id); const chapters = await adapter.listChapters(book.id);
const activeChapterId = chapters.length > 0 ? chapters[0]!.id : null; const activeChapterId = chapters.length > 0 ? chapters[0]!.id : null;
const projectStats = await computeAllProjectStats(adapter, projects); const projectStats = await computeAllProjectStats(adapter, projects);
const revisionsByChapter = loadProjectRevisions(target.id, chapters);
set({ set({
adapter, adapter,
@@ -309,6 +438,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
activeBookId: book.id, activeBookId: book.id,
chapters, chapters,
activeChapterId, activeChapterId,
revisionsByChapter,
entitiesLoaded: false, entitiesLoaded: false,
plotBeatsLoaded: false, plotBeatsLoaded: false,
boardLoaded: false, boardLoaded: false,
@@ -511,6 +641,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
}, },
scheduleSave: (ref) => { scheduleSave: (ref) => {
lastScheduledChapterId = ref.id;
// Keep the in-memory chapter doc updated so switching tabs or counting words stays live. // Keep the in-memory chapter doc updated so switching tabs or counting words stays live.
set((state) => { set((state) => {
const nextChapters = state.chapters.map((c) => const nextChapters = state.chapters.map((c) =>
@@ -542,6 +673,15 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
setHighlightedLine: (line) => { setHighlightedLine: (line) => {
set((state) => ({ set((state) => ({
highlightedLine: line, highlightedLine: line,
...(line === null ? { highlightedText: null } : {}),
highlightSeq: state.highlightSeq + 1,
}));
},
setHighlightedText: (text) => {
set((state) => ({
highlightedText: text && text.trim() ? text.trim() : null,
...(text === null ? { highlightedLine: null } : {}),
highlightSeq: state.highlightSeq + 1, highlightSeq: state.highlightSeq + 1,
})); }));
}, },
@@ -553,10 +693,44 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
const targetChapter = chapters.find((c) => c.id === targetId); const targetChapter = chapters.find((c) => c.id === targetId);
if (!targetChapter) return false; if (!targetChapter) return false;
const blocks = Array.isArray(targetChapter.doc.content) const rawBlocks = Array.isArray(targetChapter.doc.content)
? targetChapter.doc.content.map((b) => ({ ...b })) ? targetChapter.doc.content
: []; : [];
if (blocks.length === 0) return false; if (rawBlocks.length === 0) return false;
const cloneNode = (n: Record<string, unknown>): Record<string, unknown> => ({
...n,
...(Array.isArray(n.content)
? { content: (n.content as Record<string, unknown>[]).map(cloneNode) }
: {}),
});
const blocks = rawBlocks.map((b) => cloneNode(b as unknown as Record<string, unknown>));
const normQuotes = (s: string): string =>
s.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'");
const findSpanInString = (
haystack: string,
needle: string,
): { index: number; length: number } | null => {
if (!needle) return null;
const directIdx = haystack.indexOf(needle);
if (directIdx !== -1) {
return { index: directIdx, length: needle.length };
}
const normH = normQuotes(haystack);
const normN = normQuotes(needle);
const normIdx = normH.indexOf(normN);
if (normIdx !== -1) {
return { index: normIdx, length: normN.length };
}
const lowerIdx = normH.toLowerCase().indexOf(normN.toLowerCase());
if (lowerIdx !== -1) {
return { index: lowerIdx, length: normN.length };
}
return null;
};
const extractBlockText = (b: Record<string, unknown>): string => { const extractBlockText = (b: Record<string, unknown>): string => {
const parts: string[] = []; const parts: string[] = [];
@@ -572,57 +746,113 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
return parts.join(""); return parts.join("");
}; };
let targetIndex = const needle = (originalText ?? "").trim().replace(/^["“]|["”]$/g, "").trim();
lineNumber != null && lineNumber >= 1 && lineNumber <= blocks.length const replacement = newText.trim();
? lineNumber - 1 if (!replacement) return false;
: -1;
if (originalText && originalText.trim()) { let matchedBlockIndex = -1;
const needle = originalText.trim();
if ( if (needle) {
targetIndex === -1 || // 1. Try surgical in-node replacement inside a single inline text node so
!extractBlockText(blocks[targetIndex]! as unknown as Record<string, unknown>).includes(needle) // marks (bold/italic) and sibling nodes (mentions/footnotes) stay intact.
) { const replaceInsideTextNode = (node: Record<string, unknown>): boolean => {
const foundIdx = blocks.findIndex((b) => if (typeof node.text === "string") {
extractBlockText(b as unknown as Record<string, unknown>).includes(needle), const match = findSpanInString(node.text, needle);
); if (match) {
if (foundIdx !== -1) targetIndex = foundIdx; node.text =
node.text.slice(0, match.index) +
replacement +
node.text.slice(match.index + match.length);
return true;
}
}
if (Array.isArray(node.content)) {
for (const child of node.content) {
if (child && typeof child === "object" && replaceInsideTextNode(child as Record<string, unknown>)) {
return true;
}
}
}
return false;
};
// Prefer the block near lineNumber if provided, otherwise search all blocks
const candidateOrder = blocks.map((_, i) => i);
if (lineNumber != null && lineNumber >= 1 && lineNumber <= blocks.length) {
const preferred = lineNumber - 1;
candidateOrder.sort((a, b) => Math.abs(a - preferred) - Math.abs(b - preferred));
}
for (const idx of candidateOrder) {
if (replaceInsideTextNode(blocks[idx]!)) {
matchedBlockIndex = idx;
break;
} }
} }
if (targetIndex === -1) { // 2. Fallback: if the needle spans across multiple inline nodes in a block,
targetIndex = 0; // replace ONLY the matched span within that block's full text (never the whole block!).
if (matchedBlockIndex === -1) {
for (const idx of candidateOrder) {
const block = blocks[idx]!;
const fullText = extractBlockText(block);
const match = findSpanInString(fullText, needle);
if (match) {
const updatedFullText =
fullText.slice(0, match.index) +
replacement +
fullText.slice(match.index + match.length);
const safeType =
block.type === "heading" || block.type === "codeBlock"
? (block.type as string)
: "paragraph";
blocks[idx] = {
type: safeType,
...(block.attrs && typeof block.attrs === "object"
? { attrs: block.attrs as Record<string, unknown> }
: {}),
...(updatedFullText.trim()
? { content: [{ type: "text", text: updatedFullText }] }
: {}),
};
matchedBlockIndex = idx;
break;
}
}
} }
const oldBlock = blocks[targetIndex]! as unknown as Record<string, unknown>; // 3. SAFETY: If originalText was specified and does not exist anywhere in the
const oldFullText = extractBlockText(oldBlock); // chapter, NEVER overwrite a whole line or default to block 0!
const updatedLineText = ( if (matchedBlockIndex === -1) {
originalText && return false;
originalText.trim() && }
oldFullText.includes(originalText.trim()) && } else if (lineNumber != null && lineNumber >= 1 && lineNumber <= blocks.length) {
oldFullText.trim() !== originalText.trim() // Legacy fallback only when no originalText anchor was provided at all:
? oldFullText.replace(originalText.trim(), newText) // map 1-based non-empty line index to the corresponding non-empty block.
: newText const nonEmptyIndices = blocks
).trim(); .map((b, idx) => ({ idx, text: extractBlockText(b).trim() }))
.filter((x) => x.text.length > 0);
const mapped = nonEmptyIndices[lineNumber - 1]?.idx ?? lineNumber - 1;
const oldBlock = blocks[mapped]!;
const safeType = const safeType =
oldBlock.type === "heading" || oldBlock.type === "codeBlock" oldBlock.type === "heading" || oldBlock.type === "codeBlock"
? (oldBlock.type as string) ? (oldBlock.type as string)
: "paragraph"; : "paragraph";
blocks[mapped] = {
blocks[targetIndex] = {
type: safeType, type: safeType,
...(oldBlock.attrs && typeof oldBlock.attrs === "object" ...(oldBlock.attrs && typeof oldBlock.attrs === "object"
? { attrs: oldBlock.attrs as Record<string, unknown> } ? { attrs: oldBlock.attrs as Record<string, unknown> }
: {}), : {}),
...(updatedLineText content: [{ type: "text", text: replacement }],
? { content: [{ type: "text", text: updatedLineText }] }
: {}),
}; };
matchedBlockIndex = mapped;
} else {
return false;
}
const nextDoc = { const nextDoc = {
...targetChapter.doc, ...targetChapter.doc,
content: blocks, content: blocks as unknown as typeof targetChapter.doc.content,
}; };
await adapter.saveDocument({ await adapter.saveDocument({
@@ -634,13 +864,69 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
rev: targetChapter.rev, rev: targetChapter.rev,
}); });
set((state) => ({ set((state) => {
const { nextMap, nextRev } = state.project
? recordChapterRevision(
state.project.id,
state.revisionsByChapter,
{ id: targetChapter.id, title: targetChapter.title, doc: nextDoc, rev: targetChapter.rev },
"AI Edit",
)
: { nextMap: state.revisionsByChapter, nextRev: targetChapter.rev + 1 };
return {
activeChapterId: targetChapter.id, activeChapterId: targetChapter.id,
highlightedLine: targetIndex + 1, highlightedLine: matchedBlockIndex + 1,
highlightedText: replacement,
highlightSeq: state.highlightSeq + 1, highlightSeq: state.highlightSeq + 1,
docExternalRev: state.docExternalRev + 1, docExternalRev: state.docExternalRev + 1,
revisionsByChapter: nextMap,
chapters: state.chapters.map((c) => chapters: state.chapters.map((c) =>
c.id === targetChapter.id ? { ...c, doc: nextDoc, rev: c.rev + 1 } : c, c.id === targetChapter.id ? { ...c, doc: nextDoc, rev: nextRev } : c,
),
};
});
return true;
},
restoreRevision: async (chapterId, revisionId) => {
const { adapter, project, chapters, revisionsByChapter } = get();
if (!adapter || !project) return false;
const targetChapter = chapters.find((c) => c.id === chapterId);
if (!targetChapter) return false;
const revList = revisionsByChapter[chapterId] ?? [];
const targetRev = revList.find((r) => r.id === revisionId);
if (!targetRev) return false;
const restoredDoc = JSON.parse(JSON.stringify(targetRev.doc)) as Chapter["doc"];
const { nextMap, nextRev } = recordChapterRevision(
project.id,
revisionsByChapter,
{
id: targetChapter.id,
title: targetChapter.title,
doc: restoredDoc,
rev: targetChapter.rev,
},
`Restored rev #${targetRev.rev}`,
);
await adapter.saveDocument({
id: targetChapter.id,
projectId: project.id,
bookId: targetChapter.bookId,
title: targetChapter.title,
doc: restoredDoc,
rev: nextRev,
});
set((state) => ({
activeChapterId: targetChapter.id,
highlightedLine: null,
highlightedText: null,
docExternalRev: state.docExternalRev + 1,
revisionsByChapter: nextMap,
chapters: state.chapters.map((c) =>
c.id === targetChapter.id ? { ...c, doc: restoredDoc, rev: nextRev } : c,
), ),
})); }));
return true; return true;
+7 -7
View File
@@ -186,7 +186,7 @@ export const DEFAULT_AI_PROVIDERS: AIProviderSetting[] = [
]; ];
export const LINE_SUGGESTION_PROMPT_RULES = export const LINE_SUGGESTION_PROMPT_RULES =
"IMPORTANT LINE-NUMBER PROTOCOL: The active chapter is provided with 1-based line numbers ([Line 1], [Line 2], …) matching the writing editor gutter. Always cite exact line numbers (e.g. [Line 2]) in your response. Whenever you suggest edits, rewrites, or improvements to specific lines, also emit one or more structured suggestion blocks at the end of your reply in this exact format so the author can highlight the line on screen and click Accept, Deny, or Copy to Note:\n<<<SUGGESTION\nLINE: <line_number>\nTITLE: <short label>\nORIGINAL: <exact original text of that line>\nREPLACEMENT: <your revised replacement text for that line>\nSUMMARY: <1-sentence rationale>\nSUGGESTION>>>"; "IMPORTANT GRANULAR TEXT-ANCHORED PROTOCOL: Anchor every suggested edit to the EXACT verbatim phrase, clause, or single sentence from the manuscript—never rely on line numbers and never replace an entire multi-sentence paragraph at once. Quote the exact target phrase in double quotes (e.g. \"left the mainland before dawn\") in your discussion so the author can click it to highlight that exact phrase in the editor. At the end of your reply, emit 3 to 6 granular suggestion blocks targeting specific phrases, clauses, or sentences in this exact format so the author can step through and click Accept, Deny, or Copy to Note without affecting surrounding sentences:\n<<<SUGGESTION\nTITLE: <short granular edit label, e.g. Sharpen verb phrase / Cut filter word>\nORIGINAL: <exact verbatim phrase, clause, or single sentence copied character-for-character from the chapter>\nREPLACEMENT: <revised text that replaces ONLY the ORIGINAL span>\nSUMMARY: <1-sentence editorial rationale>\nSUGGESTION>>>";
export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [ export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
{ {
@@ -198,7 +198,7 @@ export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
maxTokens: 2048, maxTokens: 2048,
thinkingEnabled: false, thinkingEnabled: false,
thinkingEffort: "medium", thinkingEffort: "medium",
systemPrompt: `You are the PubScript Line Editor. Tighten prose, eliminate filter words, sharpen dialogue tags, improve pacing, flag weak openings or closings, and verify consistency against character and world sheets while preserving the author's distinct voice.\n\n${LINE_SUGGESTION_PROMPT_RULES}`, systemPrompt: `You are the PubScript Line Editor. Tighten prose at the phrase and clause level, eliminate filter words, sharpen verbs and dialogue tags, improve sentence rhythm, and verify consistency against character and world sheets while preserving the author's voice.\n\n${LINE_SUGGESTION_PROMPT_RULES}`,
}, },
{ {
id: "reader", id: "reader",
@@ -209,7 +209,7 @@ export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
maxTokens: 2048, maxTokens: 2048,
thinkingEnabled: false, thinkingEnabled: false,
thinkingEffort: "medium", thinkingEffort: "medium",
systemPrompt: `You are an honest, attentive first-time reader. Report what felt vivid, where your attention drifted, what was confusing, what was telegraphed too early, and what emotional beats paid off by citing exact [Line N] numbers.\n\n${LINE_SUGGESTION_PROMPT_RULES}`, systemPrompt: `You are an honest, attentive first-time reader. Report what felt vivid, where your attention drifted, what was confusing, what was telegraphed too early, and what emotional beats paid off by quoting exact phrases from the chapter.\n\n${LINE_SUGGESTION_PROMPT_RULES}`,
}, },
{ {
id: "publisher", id: "publisher",
@@ -220,7 +220,7 @@ export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
maxTokens: 2048, maxTokens: 2048,
thinkingEnabled: false, thinkingEnabled: false,
thinkingEffort: "medium", thinkingEffort: "medium",
systemPrompt: `You are a literary Publisher and developmental strategist. Evaluate commercial hook, genre pacing, comparable titles (comps), blurb/pitch angles, audience expectations, and structural stakes across acts, referencing specific [Line N] passages.\n\n${LINE_SUGGESTION_PROMPT_RULES}`, systemPrompt: `You are a literary Publisher and developmental strategist. Evaluate commercial hook, genre pacing, comparable titles (comps), blurb/pitch angles, audience expectations, and structural stakes across acts, quoting exact phrases and sentences.\n\n${LINE_SUGGESTION_PROMPT_RULES}`,
}, },
{ {
id: "co-author", id: "co-author",
@@ -231,7 +231,7 @@ export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
maxTokens: 4096, maxTokens: 4096,
thinkingEnabled: true, thinkingEnabled: true,
thinkingEffort: "medium", thinkingEffort: "medium",
systemPrompt: `You are a collaborative Co-Author with full knowledge of the manuscript's characters, world-building, and plot beats. Brainstorm plot turns, draft or revise specific [Line N] passages in the author's established voice, and expand world lore as adoptable line suggestions.\n\n${LINE_SUGGESTION_PROMPT_RULES}`, systemPrompt: `You are a collaborative Co-Author with full knowledge of the manuscript's characters, world-building, and plot beats. Brainstorm plot turns, propose surgical phrase and sentence revisions in the author's established voice, and expand world lore as granular adoptable suggestions.\n\n${LINE_SUGGESTION_PROMPT_RULES}`,
}, },
]; ];
@@ -305,10 +305,10 @@ function loadSettings(): AppSettings {
Array.isArray(parsed.aiAgents) && parsed.aiAgents.length > 0 Array.isArray(parsed.aiAgents) && parsed.aiAgents.length > 0
? parsed.aiAgents ? parsed.aiAgents
: DEFAULT_AI_AGENTS; : DEFAULT_AI_AGENTS;
// Upgrade any stored built-in agent whose prompt predates the line-number protocol. // Upgrade any stored built-in agent whose prompt predates the granular text-anchored protocol.
const aiAgents = rawAgents.map((agent) => { const aiAgents = rawAgents.map((agent) => {
const def = DEFAULT_AI_AGENTS.find((d) => d.id === agent.id); const def = DEFAULT_AI_AGENTS.find((d) => d.id === agent.id);
if (def && !agent.systemPrompt.includes("LINE-NUMBER PROTOCOL") && !agent.systemPrompt.includes("<<<SUGGESTION")) { if (def && !agent.systemPrompt.includes("GRANULAR TEXT-ANCHORED PROTOCOL")) {
return { ...agent, systemPrompt: def.systemPrompt }; return { ...agent, systemPrompt: def.systemPrompt };
} }
return agent; return agent;
+14 -10
View File
@@ -210,17 +210,17 @@
margin-bottom: 0.35em; margin-bottom: 0.35em;
} }
/* Line numbers in the editor left gutter */ /* Line numbers in the editor left gutter (skips blank paragraphs) */
.ps-editor-linenumbers .ProseMirror { .ps-editor-linenumbers .ProseMirror {
counter-reset: ps-line; counter-reset: ps-line;
padding-left: 44px; padding-left: 44px;
position: relative; position: relative;
} }
.ps-editor-linenumbers .ProseMirror > * { .ps-editor-linenumbers .ProseMirror > *:not(:has(> br.ProseMirror-trailingBreak:only-child)) {
counter-increment: ps-line; counter-increment: ps-line;
position: relative; position: relative;
} }
.ps-editor-linenumbers .ProseMirror > *::before { .ps-editor-linenumbers .ProseMirror > *:not(:has(> br.ProseMirror-trailingBreak:only-child))::before {
content: counter(ps-line); content: counter(ps-line);
position: absolute; position: absolute;
left: -44px; left: -44px;
@@ -236,14 +236,18 @@
pointer-events: none; pointer-events: none;
} }
/* Focus mode: dim non-active blocks while keeping the active block at full opacity */ /* Inline text-anchored AI suggestion highlight (CSS Custom Highlight API) */
.ps-editor-focus .ProseMirror > * { ::highlight(ps-ai-target) {
opacity: 0.22; background-color: #f5c542;
transition: opacity 0.15s ease; color: #14161c;
text-decoration: underline solid #d97706 2px;
} }
.ps-editor-focus .ProseMirror > .ps-focus-active {
opacity: 1; /* Focus mode: keep the writing pane at full brightness and dim surrounding UI chrome */
transition: opacity 0.08s ease; .ps-editor-focus .ps-editor-writing,
.ps-editor-focus .ProseMirror,
.ps-editor-focus .ProseMirror > * {
opacity: 1 !important;
} }
/* AI suggestion line highlight on the writing canvas */ /* AI suggestion line highlight on the writing canvas */
+116 -6
View File
@@ -144,17 +144,16 @@ body {
} }
.ps-sidebar-logo-only { .ps-sidebar-logo-only {
width: 68px; width: 88px;
height: 68px; height: auto;
border-radius: 12px; background: transparent;
display: block; display: block;
flex-shrink: 0; flex-shrink: 0;
transition: width 0.16s ease, height 0.16s ease; transition: width 0.16s ease, height 0.16s ease;
} }
.ps-sidebar.is-collapsed .ps-sidebar-logo-only { .ps-sidebar.is-collapsed .ps-sidebar-logo-only {
width: 44px; width: 48px;
height: 44px; height: auto;
border-radius: 9px;
} }
.ps-sidebar-toggle { .ps-sidebar-toggle {
@@ -1278,5 +1277,116 @@ body {
::-webkit-scrollbar-thumb { background: var(--ps-border); border-radius: 5px; border: 2px solid var(--ps-bg); } ::-webkit-scrollbar-thumb { background: var(--ps-border); border-radius: 5px; border: 2px solid var(--ps-bg); }
::-webkit-scrollbar-thumb:hover { background: var(--ps-text-muted); } ::-webkit-scrollbar-thumb:hover { background: var(--ps-text-muted); }
/* ---- Sidebar Autosave Revisions ---- */
.ps-sidebar-revisions {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--ps-border);
display: flex;
flex-direction: column;
gap: 6px;
}
.ps-sidebar-revisions-sub {
font-size: 11.5px;
font-weight: 600;
color: var(--ps-text);
padding: 0 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ps-sidebar-revisions-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 220px;
overflow-y: auto;
}
.ps-sidebar-rev-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 5px 7px;
border-radius: 6px;
background: var(--ps-surface-2);
border: 1px solid var(--ps-border);
font-size: 11px;
}
.ps-sidebar-rev-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.ps-sidebar-rev-num {
font-family: var(--ps-font-mono);
font-weight: 700;
color: var(--ps-text);
font-size: 11px;
}
.ps-sidebar-rev-meta {
font-size: 10px;
color: var(--ps-text-muted);
white-space: nowrap;
}
.ps-sidebar-rev-current {
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--ps-success) 16%, transparent);
color: var(--ps-success);
white-space: nowrap;
}
.ps-sidebar-rev-restore {
border: 1px solid color-mix(in srgb, var(--ps-accent) 55%, var(--ps-border));
background: color-mix(in srgb, var(--ps-accent) 14%, transparent);
color: var(--ps-accent);
border-radius: 6px;
padding: 2px 7px;
font-family: var(--ps-font-ui);
font-size: 10.5px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s, color 0.12s;
}
.ps-sidebar-rev-restore:hover {
background: var(--ps-accent);
color: var(--ps-accent-contrast);
}
/* ---- Focus Mode: Dim Surrounding UI Chrome, Keep Writing Pane Bright ---- */
.ps-app.is-focus-mode .ps-sidebar,
.ps-app.is-focus-mode .ps-topbar,
.ps-app.is-focus-mode .ps-writing-toolbar,
.ps-app.is-focus-mode .ps-context-rail,
.ps-app.is-focus-mode .ps-writing-status,
.ps-app.is-focus-mode .ps-ai-sidebar {
opacity: 0.18;
filter: saturate(0.45);
transition: opacity 0.22s ease, filter 0.22s ease;
}
.ps-app.is-focus-mode .ps-sidebar:hover,
.ps-app.is-focus-mode .ps-sidebar:focus-within,
.ps-app.is-focus-mode .ps-topbar:hover,
.ps-app.is-focus-mode .ps-topbar:focus-within,
.ps-app.is-focus-mode .ps-writing-toolbar:hover,
.ps-app.is-focus-mode .ps-writing-toolbar:focus-within,
.ps-app.is-focus-mode .ps-context-rail:hover,
.ps-app.is-focus-mode .ps-context-rail:focus-within,
.ps-app.is-focus-mode .ps-writing-status:hover,
.ps-app.is-focus-mode .ps-ai-sidebar:hover,
.ps-app.is-focus-mode .ps-ai-sidebar:focus-within {
opacity: 0.96;
filter: saturate(1);
}
.ps-app.is-focus-mode .ps-editor-scroll,
.ps-app.is-focus-mode .ps-editor-writing,
.ps-app.is-focus-mode .ProseMirror {
opacity: 1 !important;
filter: none !important;
}
/* ---- Focus visible ---- */ /* ---- Focus visible ---- */
:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; } :focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; }