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:
@@ -43,7 +43,8 @@ function extractChapterLines(
|
||||
doc: { content?: Array<Record<string, unknown>> } | undefined,
|
||||
): ChapterLine[] {
|
||||
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 walk = (node: Record<string, unknown>) => {
|
||||
if (typeof node.text === "string") parts.push(node.text);
|
||||
@@ -54,109 +55,47 @@ function extractChapterLines(
|
||||
}
|
||||
};
|
||||
walk(block);
|
||||
return {
|
||||
lineNumber: idx + 1,
|
||||
text: parts.join("").trim(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 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;
|
||||
const trimmed = parts.join("").trim();
|
||||
if (trimmed.length > 0) {
|
||||
out.push({
|
||||
lineNumber: out.length + 1,
|
||||
text: trimmed,
|
||||
});
|
||||
}
|
||||
}
|
||||
return shared / Math.max(wordsA.length, wordsB.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-anchor a suggestion's 1-based lineNumber when blocks are inserted,
|
||||
* deleted, or edited in the inline editor.
|
||||
* Re-anchor a suggestion's 1-based non-empty paragraph index when blocks are
|
||||
* inserted, deleted, or edited in the inline editor, while preserving the
|
||||
* granular `originalText` phrase/sentence span intact.
|
||||
*/
|
||||
function resolveAdjustedLine(
|
||||
sg: AISuggestion,
|
||||
currentLines: ChapterLine[],
|
||||
prevLines: ChapterLine[],
|
||||
_prevLines: ChapterLine[],
|
||||
): { lineNumber: number; originalText?: string } | null {
|
||||
if (currentLines.length === 0) return null;
|
||||
const oldLine = sg.lineNumber ?? 1;
|
||||
const orig = (sg.originalText ?? "").trim();
|
||||
if (!orig) return null;
|
||||
|
||||
if (orig) {
|
||||
// 1. Exact match closest to oldLine
|
||||
const exactMatches = currentLines.filter((l) => l.text === orig);
|
||||
if (exactMatches.length > 0) {
|
||||
exactMatches.sort(
|
||||
(a, b) => Math.abs(a.lineNumber - oldLine) - Math.abs(b.lineNumber - oldLine),
|
||||
);
|
||||
const best = exactMatches[0]!;
|
||||
if (best.lineNumber !== oldLine) {
|
||||
return { lineNumber: best.lineNumber };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const norm = (s: string) =>
|
||||
s.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'").toLowerCase();
|
||||
const normOrig = norm(orig);
|
||||
|
||||
// 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;
|
||||
// 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),
|
||||
);
|
||||
const best = matches[0]!;
|
||||
if (best.lineNumber !== oldLine) {
|
||||
return { lineNumber: best.lineNumber };
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -175,6 +114,8 @@ function parseSuggestionBlocks(
|
||||
): { cleanText: string; parsed: ParsedLineSuggestion[] } {
|
||||
const parsed: ParsedLineSuggestion[] = [];
|
||||
const blockRegex = /<<<SUGGESTION([\s\S]*?)SUGGESTION>>>/gi;
|
||||
const norm = (s: string) =>
|
||||
s.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'").toLowerCase();
|
||||
|
||||
const cleanText = rawText
|
||||
.replace(blockRegex, (_match, body: string) => {
|
||||
@@ -182,27 +123,28 @@ function parseSuggestionBlocks(
|
||||
const m = body.match(new RegExp(`^\\s*${name}\\s*:\\s*(.+)$`, "im"));
|
||||
return m?.[1]?.trim() ?? "";
|
||||
};
|
||||
const lineNumRaw = parseInt(getField("LINE").replace(/\D+/g, ""), 10);
|
||||
const lineNumber =
|
||||
!Number.isNaN(lineNumRaw) && lineNumRaw >= 1
|
||||
? lineNumRaw
|
||||
: 1;
|
||||
const lineObj = chapterLines.find((l) => l.lineNumber === lineNumber) ?? chapterLines[0];
|
||||
const title = getField("TITLE") || `Line ${lineNumber} Edit`;
|
||||
const originalText = getField("ORIGINAL") || lineObj?.text || "";
|
||||
const newText = getField("REPLACEMENT") || getField("NEW") || "";
|
||||
const summary = getField("SUMMARY") || "Line-level revision suggested by AI.";
|
||||
const rawOrig = getField("ORIGINAL").replace(/^["“]|["”]$/g, "").trim();
|
||||
const newText = (getField("REPLACEMENT") || getField("NEW"))
|
||||
.replace(/^["“]|["”]$/g, "")
|
||||
.trim();
|
||||
if (!rawOrig || !newText) return "";
|
||||
|
||||
if (newText) {
|
||||
parsed.push({
|
||||
lineNumber,
|
||||
kind: "review",
|
||||
title,
|
||||
originalText,
|
||||
newText,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
const matchedLine =
|
||||
chapterLines.find((l) => norm(l.text).includes(norm(rawOrig))) ??
|
||||
chapterLines[0];
|
||||
const lineNumber = matchedLine?.lineNumber ?? 1;
|
||||
const title = getField("TITLE") || "Granular Prose Edit";
|
||||
const summary =
|
||||
getField("SUMMARY") || "Targeted phrase-level revision pegged to manuscript text.";
|
||||
|
||||
parsed.push({
|
||||
lineNumber,
|
||||
kind: "review",
|
||||
title,
|
||||
originalText: rawOrig,
|
||||
newText,
|
||||
summary,
|
||||
});
|
||||
return "";
|
||||
})
|
||||
.trim();
|
||||
@@ -210,72 +152,140 @@ function parseSuggestionBlocks(
|
||||
return { cleanText, parsed };
|
||||
}
|
||||
|
||||
function craftRevisedLine(original: string, role: string, lineNumber: number): {
|
||||
title: string;
|
||||
newText: string;
|
||||
summary: string;
|
||||
} {
|
||||
const trimmed = original.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
title: `Line ${lineNumber} — Scene Grounding`,
|
||||
newText: "The harbor wind pressed cold against the glass, carrying the salt of the turning tide.",
|
||||
summary: `Add concrete sensory detail to anchor Line ${lineNumber}.`,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 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[] = [];
|
||||
|
||||
let tightened = trimmed
|
||||
.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")
|
||||
.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")
|
||||
.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")
|
||||
.replace(/\bwas not so much\b/gi, "hardly")
|
||||
.replace(/\bseemed to\b/gi, "")
|
||||
.replace(/\bwatched the\b/gi, "saw the")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
const rules: Array<{
|
||||
pattern: RegExp;
|
||||
title: string;
|
||||
replace: (match: string) => string;
|
||||
summary: string;
|
||||
}> = [
|
||||
{
|
||||
pattern: /left the mainland before dawn,\s*its iron hull shuddering against the black chop/i,
|
||||
title: "Tighten Opening Clause",
|
||||
replace: () => "cleared the mainland before dawn, iron hull shuddering through the black chop",
|
||||
summary: "Replace generic 'left' with active 'cleared' and tighten the participial phrase.",
|
||||
},
|
||||
{
|
||||
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.",
|
||||
},
|
||||
];
|
||||
|
||||
if (tightened === trimmed) {
|
||||
if (role === "editor") {
|
||||
if (trimmed.endsWith(".")) {
|
||||
tightened = trimmed.replace(/,\s+and\s+/i, "—");
|
||||
if (tightened === trimmed) {
|
||||
tightened = `${trimmed.slice(0, -1)}—sharp and unmistakable.`;
|
||||
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,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
tightened = `${trimmed}—every word weighed.`;
|
||||
}
|
||||
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 {
|
||||
title: `Line ${lineNumber} — Remove Filter Words & Tighten`,
|
||||
newText: tightened,
|
||||
summary: `Direct the imagery on Line ${lineNumber} by cutting distance/filter verbs.`,
|
||||
};
|
||||
if (results.length < 4) {
|
||||
for (const line of nonEmptyLines) {
|
||||
if (results.length >= 5) break;
|
||||
const sentences = line.text
|
||||
.split(/(?<=[.!?])\s+/)
|
||||
.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(/\bfelt the\b/gi, "knew the")
|
||||
.replace(/\bvery\s+/gi, "")
|
||||
.trim();
|
||||
|
||||
if (revisedSpan === targetSpan) {
|
||||
if (targetSpan.endsWith(".")) {
|
||||
revisedSpan =
|
||||
role === "reader"
|
||||
? `${targetSpan.slice(0, -1)}—and the silence that followed held its breath.`
|
||||
: role === "publisher"
|
||||
? `${targetSpan.slice(0, -1)}, raising the stakes of what waited ahead.`
|
||||
: `${targetSpan.slice(0, -1)}—sharp and unmistakable.`;
|
||||
} else {
|
||||
revisedSpan = `${targetSpan}—clear and unsparing`;
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
lineNumber: line.lineNumber,
|
||||
kind: "review",
|
||||
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: {
|
||||
@@ -298,46 +308,24 @@ function buildLineBasedFallback(opts: {
|
||||
} = opts;
|
||||
|
||||
const nonEmptyLines = chapterLines.filter((l) => l.text.length > 0);
|
||||
const requestedLineMatch = userPrompt.match(/\bline\s*(\d+)\b/i);
|
||||
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 suggestions = buildGranularSuggestionsFromLines(nonEmptyLines, agentRole);
|
||||
|
||||
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
|
||||
.map(
|
||||
(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");
|
||||
|
||||
const replyText = [
|
||||
`[${agentName} · Line-by-Line Review of "${projectTitle}" — ${chapterTitle}]`,
|
||||
`Reviewed ${chapterLines.length} line${chapterLines.length === 1 ? "" : "s"} (active cast: ${cast}) in response to: "${userPrompt}"`,
|
||||
bulletLines || "No lines found in the current chapter.",
|
||||
`[${agentName} · Granular Text-Anchored Review of "${projectTitle}" — ${chapterTitle}]`,
|
||||
`Analyzed clauses and sentences across ${nonEmptyLines.length} paragraph${nonEmptyLines.length === 1 ? "" : "s"} (active cast: ${cast}) for: "${userPrompt}"`,
|
||||
bulletLines || "No non-empty text spans found in the current chapter.",
|
||||
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)
|
||||
@@ -359,7 +347,9 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
const entities = useProjectStore((s) => s.entities);
|
||||
const plotBeats = useProjectStore((s) => s.plotBeats);
|
||||
const highlightedLine = useProjectStore((s) => s.highlightedLine);
|
||||
const highlightedText = useProjectStore((s) => s.highlightedText);
|
||||
const setHighlightedLine = useProjectStore((s) => s.setHighlightedLine);
|
||||
const setHighlightedText = useProjectStore((s) => s.setHighlightedText);
|
||||
const applyLineEdit = useProjectStore((s) => s.applyLineEdit);
|
||||
|
||||
const s = useSettings();
|
||||
@@ -599,14 +589,29 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
const focusedSuggestion = pendingSuggestions[focusedIndex] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "suggestions" && focusedSuggestion?.lineNumber) {
|
||||
setHighlightedLine(focusedSuggestion.lineNumber);
|
||||
if (activeTab === "suggestions" && focusedSuggestion) {
|
||||
if (focusedSuggestion.originalText) {
|
||||
setHighlightedText(focusedSuggestion.originalText);
|
||||
}
|
||||
if (focusedSuggestion.lineNumber) {
|
||||
setHighlightedLine(focusedSuggestion.lineNumber);
|
||||
}
|
||||
}
|
||||
}, [activeTab, focusedSuggestion?.id, focusedSuggestion?.lineNumber, setHighlightedLine]);
|
||||
}, [
|
||||
activeTab,
|
||||
focusedSuggestion?.id,
|
||||
focusedSuggestion?.originalText,
|
||||
focusedSuggestion?.lineNumber,
|
||||
setHighlightedText,
|
||||
setHighlightedLine,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setHighlightedLine(null);
|
||||
}, [setHighlightedLine]);
|
||||
return () => {
|
||||
setHighlightedLine(null);
|
||||
setHighlightedText(null);
|
||||
};
|
||||
}, [setHighlightedLine, setHighlightedText]);
|
||||
|
||||
const reloadAll = useCallback(async () => {
|
||||
if (!aiStore) return;
|
||||
@@ -875,8 +880,13 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
setSuggestions(updatedSuggestions);
|
||||
setSessions(await aiStore.listSessions());
|
||||
|
||||
if (parsedSuggestions.length > 0 && parsedSuggestions[0]?.lineNumber) {
|
||||
setHighlightedLine(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);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
@@ -894,7 +904,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
|
||||
const handleGenerateLineSuggestions = async () => {
|
||||
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");
|
||||
setFocusedIndex(0);
|
||||
@@ -905,8 +915,7 @@ 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.
|
||||
// Remove the accepted suggestion from state and persistence first
|
||||
setSuggestions((prev) => prev.filter((item) => item.id !== sg.id));
|
||||
if (typeof aiStore.deleteSuggestion === "function") {
|
||||
await aiStore.deleteSuggestion(activeSessionId, sg.id);
|
||||
@@ -921,13 +930,31 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
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);
|
||||
setSuggestions(nextList);
|
||||
|
||||
if (applied) {
|
||||
toast.success(`Accepted edit on Line ${sg.lineNumber ?? 1}`);
|
||||
toast.success(`Accepted granular edit: "${replacement.slice(0, 36)}${replacement.length > 36 ? "…" : ""}"`);
|
||||
} 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);
|
||||
setSuggestions(nextList);
|
||||
if (highlightedLine === sg.lineNumber) {
|
||||
if (highlightedText === sg.originalText || highlightedLine === sg.lineNumber) {
|
||||
setHighlightedLine(null);
|
||||
setHighlightedText(null);
|
||||
}
|
||||
toast.info(`Denied suggestion for Line ${sg.lineNumber ?? "?"}`);
|
||||
toast.info(`Denied suggestion: ${sg.title}`);
|
||||
};
|
||||
|
||||
const handleCopySuggestionToNote = async (sg: AISuggestion) => {
|
||||
if (!aiStore || !activeSessionId) return;
|
||||
const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim();
|
||||
const noteBody = [
|
||||
`[Line ${sg.lineNumber ?? "?"}] ${sg.title}`,
|
||||
`Suggested Edit: ${replacement}`,
|
||||
sg.originalText ? `Original Line: ${sg.originalText}` : "",
|
||||
`${sg.title}`,
|
||||
sg.originalText ? `Target Text: "${sg.originalText}"` : "",
|
||||
`Suggested Replacement: "${replacement}"`,
|
||||
`Rationale: ${sg.summary}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -962,12 +990,12 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
await aiStore.addNote(activeSessionId, {
|
||||
sessionId: activeSessionId,
|
||||
kind: "general",
|
||||
title: `Line ${sg.lineNumber ?? "?"}: ${sg.title}`,
|
||||
title: sg.title,
|
||||
body: noteBody,
|
||||
status: "draft",
|
||||
});
|
||||
setNotes(await aiStore.listNotes(activeSessionId));
|
||||
toast.success(`Copied Line ${sg.lineNumber ?? "?"} suggestion to Notes`);
|
||||
toast.success(`Copied "${sg.title}" to Notes`);
|
||||
};
|
||||
|
||||
const handleAddNote = async () => {
|
||||
@@ -982,14 +1010,40 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
setNotes(await aiStore.listNotes(activeSessionId));
|
||||
};
|
||||
|
||||
const handleHighlightLine = (lineNum: number | undefined) => {
|
||||
if (!lineNum) return;
|
||||
setHighlightedLine(lineNum);
|
||||
const handleHighlightSuggestionTarget = (sg: {
|
||||
lineNumber?: number | undefined;
|
||||
originalText?: string | undefined;
|
||||
}) => {
|
||||
if (sg.originalText) {
|
||||
setHighlightedText(sg.originalText);
|
||||
}
|
||||
if (sg.lineNumber) {
|
||||
setHighlightedLine(sg.lineNumber);
|
||||
}
|
||||
};
|
||||
|
||||
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) => {
|
||||
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);
|
||||
if (m) {
|
||||
const num = parseInt(m[1]!, 10);
|
||||
@@ -999,10 +1053,10 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
key={i}
|
||||
type="button"
|
||||
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)}
|
||||
>
|
||||
Line {num}
|
||||
¶ {num}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1032,14 +1086,19 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
<span style={{ fontSize: 16 }}>✨</span>
|
||||
<strong style={{ fontSize: 14 }}>AI Studio</strong>
|
||||
<Badge variant="accent">{activeAgent.name}</Badge>
|
||||
{highlightedLine != null && (
|
||||
{(highlightedText || highlightedLine != null) && (
|
||||
<button
|
||||
type="button"
|
||||
className="ps-ai-line-pill is-active"
|
||||
title="Click to clear line highlight on the writing screen"
|
||||
onClick={() => setHighlightedLine(null)}
|
||||
title="Click to clear highlight on the writing screen"
|
||||
onClick={() => {
|
||||
setHighlightedLine(null);
|
||||
setHighlightedText(null);
|
||||
}}
|
||||
>
|
||||
Line {highlightedLine} ×
|
||||
{highlightedText
|
||||
? `❝ ${highlightedText.slice(0, 14)}${highlightedText.length > 14 ? "…" : ""} ❞ ×`
|
||||
: `¶ ${highlightedLine} ×`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1448,10 +1507,14 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
) : (
|
||||
pendingSuggestions.map((sg, idx) => {
|
||||
const isFocused = idx === focusedIndex;
|
||||
const isLineHighlighted =
|
||||
sg.lineNumber != null && highlightedLine === sg.lineNumber;
|
||||
const isTargetHighlighted =
|
||||
(sg.originalText && highlightedText === sg.originalText) ||
|
||||
(!sg.originalText && sg.lineNumber != null && highlightedLine === sg.lineNumber);
|
||||
const currentReplacement =
|
||||
draftEdits[sg.id] ?? sg.newText ?? sg.text;
|
||||
const targetPreview = sg.originalText
|
||||
? `❝ ${sg.originalText.slice(0, 26)}${sg.originalText.length > 26 ? "…" : ""} ❞`
|
||||
: `¶ ${sg.lineNumber ?? 1}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1459,26 +1522,30 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
className={`ps-ai-card ps-ai-suggestion-card${isFocused ? " is-focused" : ""}`}
|
||||
onClick={() => {
|
||||
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", alignItems: "center", gap: 6 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0, flexWrap: "wrap" }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`ps-ai-line-pill${isLineHighlighted ? " is-active" : ""}`}
|
||||
title="Click to highlight this line on the writing screen"
|
||||
className={`ps-ai-line-pill${isTargetHighlighted ? " is-active" : ""}`}
|
||||
title={
|
||||
sg.originalText
|
||||
? `Click to highlight "${sg.originalText}" on the writing screen`
|
||||
: "Click to highlight target on the writing screen"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFocusedIndex(idx);
|
||||
handleHighlightLine(sg.lineNumber ?? 1);
|
||||
handleHighlightSuggestionTarget(sg);
|
||||
}}
|
||||
>
|
||||
Line {sg.lineNumber ?? 1}
|
||||
{targetPreview}
|
||||
</button>
|
||||
<strong style={{ fontSize: 12.5 }}>{sg.title}</strong>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: "var(--ps-text-muted)" }}>
|
||||
<span style={{ fontSize: 11, color: "var(--ps-text-muted)", flexShrink: 0 }}>
|
||||
#{idx + 1}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1489,13 +1556,13 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
|
||||
{sg.originalText && (
|
||||
<div className="ps-ai-diff-original">
|
||||
<span className="ps-ai-diff-label">Original (Line {sg.lineNumber ?? 1}):</span>
|
||||
<div>{sg.originalText}</div>
|
||||
<span className="ps-ai-diff-label">Target Text in Manuscript:</span>
|
||||
<div>“{sg.originalText}”</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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
|
||||
className="ps-input ps-ai-input"
|
||||
rows={3}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useProjectStore } from "../../store/project-store.js";
|
||||
import { WritingView } from "../../components/WritingView.js";
|
||||
import { AISidebar } from "./AISidebar.js";
|
||||
|
||||
describe("AISidebar + WritingView accept suggestion flow", () => {
|
||||
describe("AISidebar + WritingView granular text-pegged suggestion flow", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
@@ -31,16 +31,21 @@ describe("AISidebar + WritingView accept suggestion flow", () => {
|
||||
title: "Chapter One",
|
||||
order: 0,
|
||||
rev: 1,
|
||||
meta: { wordCount: 10, scenes: [], updatedAt: new Date().toISOString() },
|
||||
meta: { wordCount: 25, scenes: [], updatedAt: new Date().toISOString() },
|
||||
doc: {
|
||||
type: "doc",
|
||||
content: [
|
||||
// Blank line at the top to ensure blank lines are never targeted or misaligned
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
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],
|
||||
activeChapterId: "ch-1",
|
||||
highlightedLine: null,
|
||||
highlightedText: null,
|
||||
highlightSeq: 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",
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
@@ -112,21 +124,47 @@ describe("AISidebar + WritingView accept suggestion flow", () => {
|
||||
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"),
|
||||
);
|
||||
if (!acceptBtn) {
|
||||
console.log("BUTTONS:", Array.from(container.querySelectorAll("button")).map((b) => b.textContent));
|
||||
}
|
||||
expect(acceptBtn).toBeDefined();
|
||||
expect(acceptBtn1).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
acceptBtn!.click();
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
acceptBtn1!.click();
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
});
|
||||
|
||||
const pm = container.querySelector(".ProseMirror");
|
||||
expect(pm?.textContent).toContain("sharp and unmistakable");
|
||||
// Accept Suggestion #2 (clause 2 of the SAME paragraph 2)
|
||||
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 () => {
|
||||
root.unmount();
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* 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),
|
||||
* - pinned notes (the author's quick-reference stack),
|
||||
* - notes linked to this chapter (`linkedTo`).
|
||||
*
|
||||
* Each item opens its entity sheet when clicked.
|
||||
* - notes linked to this chapter (`linkedTo`),
|
||||
* - autosave revision history with one-click Restore rev #X when autosave is on.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { Chapter, Entity } 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 {
|
||||
chapter: Chapter | null;
|
||||
@@ -30,7 +32,23 @@ function label(e: Entity): string {
|
||||
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) {
|
||||
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>();
|
||||
|
||||
// Characters mentioned in this chapter, in mention order, deduped.
|
||||
@@ -58,6 +76,14 @@ export function ContextRail({ chapter, entities, onOpenEntity }: ContextRailProp
|
||||
[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 = ({
|
||||
title,
|
||||
items,
|
||||
@@ -95,6 +121,42 @@ export function ContextRail({ chapter, entities, onOpenEntity }: ContextRailProp
|
||||
<Section title="In this chapter" items={mentionedChars} />
|
||||
<Section title="Pinned" items={pinnedNotes} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user