feat: use enlarged single PS logo and add line-numbered AI suggestions with highlight/accept/deny/copy-to-note
This commit is contained in:
@@ -7,10 +7,11 @@ import type {
|
||||
AISession,
|
||||
AISuggestion,
|
||||
NoteKind,
|
||||
SuggestionKind,
|
||||
} from "@pubscript/core";
|
||||
import { Badge, Button, toast } from "@pubscript/ui";
|
||||
import { useProjectStore } from "../../store/project-store.js";
|
||||
import { useSettings } from "../../store/settings.js";
|
||||
import { useSettings, LINE_SUGGESTION_PROMPT_RULES } from "../../store/settings.js";
|
||||
|
||||
type SidebarTab = "chat" | "suggestions" | "notes";
|
||||
|
||||
@@ -22,45 +23,221 @@ const NOTE_KINDS: { value: NoteKind; label: string }[] = [
|
||||
{ value: "general", label: "General" },
|
||||
];
|
||||
|
||||
function extractPlainText(doc: { content?: Array<Record<string, unknown>> } | undefined): string {
|
||||
if (!doc) return "";
|
||||
const parts: string[] = [];
|
||||
const walk = (node: Record<string, unknown>) => {
|
||||
if (typeof node.text === "string") parts.push(node.text);
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const c of node.content) {
|
||||
if (c && typeof c === "object") walk(c as Record<string, unknown>);
|
||||
}
|
||||
parts.push("\n");
|
||||
}
|
||||
};
|
||||
walk(doc as unknown as Record<string, unknown>);
|
||||
return parts.join("").trim();
|
||||
export interface ChapterLine {
|
||||
lineNumber: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function buildFallbackAgentReply(opts: {
|
||||
function extractChapterLines(
|
||||
doc: { content?: Array<Record<string, unknown>> } | undefined,
|
||||
): ChapterLine[] {
|
||||
if (!doc || !Array.isArray(doc.content)) return [];
|
||||
return doc.content.map((block, idx) => {
|
||||
const parts: string[] = [];
|
||||
const walk = (node: Record<string, unknown>) => {
|
||||
if (typeof node.text === "string") parts.push(node.text);
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const c of node.content) {
|
||||
if (c && typeof c === "object") walk(c as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(block);
|
||||
return {
|
||||
lineNumber: idx + 1,
|
||||
text: parts.join("").trim(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface ParsedLineSuggestion {
|
||||
lineNumber: number;
|
||||
kind: SuggestionKind;
|
||||
title: string;
|
||||
originalText: string;
|
||||
newText: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
function parseSuggestionBlocks(
|
||||
rawText: string,
|
||||
chapterLines: ChapterLine[],
|
||||
): { cleanText: string; parsed: ParsedLineSuggestion[] } {
|
||||
const parsed: ParsedLineSuggestion[] = [];
|
||||
const blockRegex = /<<<SUGGESTION([\s\S]*?)SUGGESTION>>>/gi;
|
||||
|
||||
const cleanText = rawText
|
||||
.replace(blockRegex, (_match, body: string) => {
|
||||
const getField = (name: string): string => {
|
||||
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.";
|
||||
|
||||
if (newText) {
|
||||
parsed.push({
|
||||
lineNumber,
|
||||
kind: "review",
|
||||
title,
|
||||
originalText,
|
||||
newText,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.trim();
|
||||
|
||||
return { cleanText, parsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Polish a line of prose according to the agent role so every generated line
|
||||
* suggestion offers a concrete, meaningful edit for that exact line number.
|
||||
*/
|
||||
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}.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Tighten common filter phrases if present
|
||||
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();
|
||||
|
||||
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.`;
|
||||
}
|
||||
} 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.`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLineBasedFallback(opts: {
|
||||
agentName: string;
|
||||
agentRole: string;
|
||||
userPrompt: string;
|
||||
projectTitle: string;
|
||||
chapterTitle: string;
|
||||
chapterExcerpt: string;
|
||||
chapterLines: ChapterLine[];
|
||||
characterNames: string[];
|
||||
}): string {
|
||||
const { agentName, agentRole, userPrompt, projectTitle, chapterTitle, chapterExcerpt, characterNames } = opts;
|
||||
const cast = characterNames.length > 0 ? characterNames.slice(0, 5).join(", ") : "your cast";
|
||||
const snippet = chapterExcerpt ? `“${chapterExcerpt.slice(0, 180)}…”` : "the current chapter draft";
|
||||
}): { replyText: string; suggestions: ParsedLineSuggestion[] } {
|
||||
const {
|
||||
agentName,
|
||||
agentRole,
|
||||
userPrompt,
|
||||
projectTitle,
|
||||
chapterTitle,
|
||||
chapterLines,
|
||||
characterNames,
|
||||
} = opts;
|
||||
|
||||
if (agentRole === "editor") {
|
||||
return `[${agentName} · Manuscript Analysis for "${projectTitle}" — ${chapterTitle}]\n\nRegarding "${userPrompt}":\n• Prose & Pacing: Looking at ${snippet}, the sensory grounding is strong. Consider tightening filter verbs (e.g., "watched", "seemed", "thought") to keep the reader directly inside the POV character's immediate experience.\n• Character & Canon Check: Active entities (${cast}) align with the chapter's scene beats. Ensure dialogue rhythms stay distinct between speakers.\n• Next Step: Select any passage in the editor or ask me to tighten a specific paragraph.`;
|
||||
const nonEmptyLines = chapterLines.filter((l) => l.text.length > 0);
|
||||
// Check if user asked about a specific line number, e.g. "line 3"
|
||||
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 (agentRole === "reader") {
|
||||
return `[${agentName} · First-Time Reader Response — ${chapterTitle}]\n\nReading through ${chapterTitle} with your question ("${userPrompt}") in mind:\n• What hooked me: The atmosphere in ${snippet} immediately establishes tension and place.\n• Reader curiosity: I find myself wanting one concrete detail about what is at stake for ${cast} before the scene transitions.\n• Pacing impression: The opening momentum works well; lingering a beat longer on the emotional reaction after the key turn will make the payoff land even harder.`;
|
||||
if (targetLines.length === 0) {
|
||||
targetLines = nonEmptyLines.slice(0, Math.min(3, nonEmptyLines.length));
|
||||
}
|
||||
if (agentRole === "publisher") {
|
||||
return `[${agentName} · Market & Developmental Notes for "${projectTitle}"]\n\nAddressing "${userPrompt}":\n• Hook & Positioning: "${projectTitle}" combines atmospheric tension with strong character stakes around ${cast}.\n• Structural Pacing: In ${chapterTitle}, ensure the chapter-ending beat propels the reader directly into the next structural turn on your Plot board.\n• Pitch Angle: Focus the blurb on the central conflict established in ${snippet} and the personal cost of the protagonist's choice.`;
|
||||
}
|
||||
return `[${agentName} · Co-Author Draft & Brainstorm — ${chapterTitle}]\n\nBuilding on "${userPrompt}" for "${projectTitle}" (${cast}):\n\nHere is a scene continuation / variation anchored to ${chapterTitle}:\n\nThe silence that followed was heavier than the wind outside. Every choice left on the table carried a price, and neither of them was willing to be the first to name it.\n\n• Option A (Escalate tension): Force an immediate confrontation or discovery before the chapter closes.\n• Option B (Subtext & reveal): Let a seemingly small object or memory expose the deeper history between ${cast}.`;
|
||||
|
||||
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 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 ? "…" : ""}”`,
|
||||
)
|
||||
.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.",
|
||||
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.`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
return { replyText, suggestions };
|
||||
}
|
||||
|
||||
export interface AISidebarProps {
|
||||
@@ -75,6 +252,9 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
const activeChapterId = useProjectStore((s) => s.activeChapterId);
|
||||
const entities = useProjectStore((s) => s.entities);
|
||||
const plotBeats = useProjectStore((s) => s.plotBeats);
|
||||
const highlightedLine = useProjectStore((s) => s.highlightedLine);
|
||||
const setHighlightedLine = useProjectStore((s) => s.setHighlightedLine);
|
||||
const applyLineEdit = useProjectStore((s) => s.applyLineEdit);
|
||||
|
||||
const s = useSettings();
|
||||
const setSettings = useSettings((st) => st.set);
|
||||
@@ -101,6 +281,8 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
const [suggestions, setSuggestions] = useState<AISuggestion[]>([]);
|
||||
const [notes, setNotes] = useState<AINote[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<SidebarTab>("chat");
|
||||
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||
const [draftEdits, setDraftEdits] = useState<Record<string, string>>({});
|
||||
const [input, setInput] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
@@ -109,6 +291,16 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const activeChapter = useMemo(
|
||||
() => chapters.find((c) => c.id === activeChapterId) ?? chapters[0] ?? null,
|
||||
[chapters, activeChapterId],
|
||||
);
|
||||
|
||||
const chapterLines = useMemo(
|
||||
() => extractChapterLines(activeChapter?.doc),
|
||||
[activeChapter?.doc],
|
||||
);
|
||||
|
||||
const activeProvider = useMemo(
|
||||
() =>
|
||||
s.aiProviders.find((p) => p.id === s.activeProviderId) ??
|
||||
@@ -144,6 +336,35 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
[sessions, activeSessionId],
|
||||
);
|
||||
|
||||
const pendingSuggestions = useMemo(
|
||||
() =>
|
||||
suggestions
|
||||
.filter((sg) => sg.status === "pending")
|
||||
.sort((a, b) => (a.lineNumber ?? 9999) - (b.lineNumber ?? 9999)),
|
||||
[suggestions],
|
||||
);
|
||||
|
||||
// Keep focusedIndex in bounds of pendingSuggestions
|
||||
useEffect(() => {
|
||||
if (focusedIndex >= pendingSuggestions.length) {
|
||||
setFocusedIndex(Math.max(0, pendingSuggestions.length - 1));
|
||||
}
|
||||
}, [focusedIndex, pendingSuggestions.length]);
|
||||
|
||||
const focusedSuggestion = pendingSuggestions[focusedIndex] ?? null;
|
||||
|
||||
// Automatically highlight the focused suggestion's line when viewing the Suggestions tab
|
||||
useEffect(() => {
|
||||
if (activeTab === "suggestions" && focusedSuggestion?.lineNumber) {
|
||||
setHighlightedLine(focusedSuggestion.lineNumber);
|
||||
}
|
||||
}, [activeTab, focusedSuggestion?.id, focusedSuggestion?.lineNumber, setHighlightedLine]);
|
||||
|
||||
// Clear highlight when unmounting
|
||||
useEffect(() => {
|
||||
return () => setHighlightedLine(null);
|
||||
}, [setHighlightedLine]);
|
||||
|
||||
const reloadAll = useCallback(async () => {
|
||||
if (!aiStore) return;
|
||||
let list = await aiStore.listSessions();
|
||||
@@ -239,9 +460,8 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!aiStore || !input.trim() || sending) return;
|
||||
const runPromptWithLineSuggestions = async (promptText: string) => {
|
||||
if (!aiStore || sending) return;
|
||||
|
||||
let sessionId = activeSessionId;
|
||||
if (!sessionId) {
|
||||
@@ -254,27 +474,26 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
setActiveSessionId(sessionId);
|
||||
}
|
||||
|
||||
const text = input.trim();
|
||||
setInput("");
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
await aiStore.addMessage(sessionId, {
|
||||
role: "user",
|
||||
content: text,
|
||||
content: promptText,
|
||||
});
|
||||
setMessages(await aiStore.listMessages(sessionId));
|
||||
|
||||
const activeChapter =
|
||||
chapters.find((c) => c.id === activeChapterId) ?? chapters[0];
|
||||
const chapterText = extractPlainText(activeChapter?.doc);
|
||||
const numberedChapterText = chapterLines
|
||||
.map((l) => `[Line ${l.lineNumber}] ${l.text}`)
|
||||
.join("\n");
|
||||
const characterNames = entities
|
||||
.filter((ent) => !("fields" in ent) && !("body" in ent))
|
||||
.map((ent) => ("name" in ent ? ent.name : ""))
|
||||
.filter(Boolean);
|
||||
const contextText = [
|
||||
`Project: ${project?.title ?? "Untitled"}`,
|
||||
activeChapter ? `Active Chapter: ${activeChapter.title}\n${chapterText.slice(0, 2000)}` : "",
|
||||
activeChapter
|
||||
? `Active Chapter: ${activeChapter.title} (${chapterLines.length} lines)\n${numberedChapterText}`
|
||||
: "",
|
||||
characterNames.length > 0 ? `Characters: ${characterNames.join(", ")}` : "",
|
||||
plotBeats.length > 0
|
||||
? `Plot Beats: ${plotBeats.slice(0, 8).map((b) => `Act ${b.act}: ${b.title}`).join(" | ")}`
|
||||
@@ -283,7 +502,13 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
const systemPromptWithLines = activeAgent.systemPrompt.includes("<<<SUGGESTION")
|
||||
? activeAgent.systemPrompt
|
||||
: `${activeAgent.systemPrompt}\n\n${LINE_SUGGESTION_PROMPT_RULES}`;
|
||||
|
||||
let assistantReply = "";
|
||||
let parsedSuggestions: ParsedLineSuggestion[] = [];
|
||||
|
||||
try {
|
||||
const controller = new AgentController({
|
||||
id: activeProvider.id,
|
||||
@@ -313,7 +538,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
[
|
||||
{
|
||||
role: "system",
|
||||
content: `${activeAgent.systemPrompt}\n\n--- MANUSCRIPT CONTEXT ---\n${contextText}`,
|
||||
content: `${systemPromptWithLines}\n\n--- LINE-NUMBERED MANUSCRIPT CONTEXT ---\n${contextText}`,
|
||||
},
|
||||
...historyMsgs,
|
||||
],
|
||||
@@ -323,17 +548,51 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
contextText,
|
||||
},
|
||||
);
|
||||
assistantReply = result.text.trim();
|
||||
const extracted = parseSuggestionBlocks(result.text.trim(), chapterLines);
|
||||
assistantReply = extracted.cleanText;
|
||||
parsedSuggestions = extracted.parsed;
|
||||
|
||||
// If the model responded without explicit <<<SUGGESTION blocks, still
|
||||
// generate concrete line-numbered suggestions for any [Line N] it cited!
|
||||
if (parsedSuggestions.length === 0 && chapterLines.length > 0) {
|
||||
const fallback = buildLineBasedFallback({
|
||||
agentName: activeAgent.name,
|
||||
agentRole: activeAgent.role,
|
||||
userPrompt: promptText,
|
||||
projectTitle: project?.title ?? "Untitled Project",
|
||||
chapterTitle: activeChapter?.title ?? "Chapter 1",
|
||||
chapterLines,
|
||||
characterNames,
|
||||
});
|
||||
parsedSuggestions = fallback.suggestions;
|
||||
}
|
||||
} catch {
|
||||
assistantReply = buildFallbackAgentReply({
|
||||
const fallback = buildLineBasedFallback({
|
||||
agentName: activeAgent.name,
|
||||
agentRole: activeAgent.role,
|
||||
userPrompt: text,
|
||||
userPrompt: promptText,
|
||||
projectTitle: project?.title ?? "Untitled Project",
|
||||
chapterTitle: activeChapter?.title ?? "Chapter 1",
|
||||
chapterExcerpt: chapterText,
|
||||
chapterLines,
|
||||
characterNames,
|
||||
});
|
||||
assistantReply = fallback.replyText;
|
||||
parsedSuggestions = fallback.suggestions;
|
||||
}
|
||||
|
||||
for (const item of parsedSuggestions) {
|
||||
await aiStore.addSuggestion(sessionId, {
|
||||
sessionId,
|
||||
kind: item.kind,
|
||||
title: item.title,
|
||||
text: item.newText,
|
||||
newText: item.newText,
|
||||
originalText: item.originalText,
|
||||
lineNumber: item.lineNumber,
|
||||
...(activeChapter?.id ? { chapterId: activeChapter.id } : {}),
|
||||
summary: item.summary,
|
||||
status: "pending",
|
||||
});
|
||||
}
|
||||
|
||||
if (assistantReply) {
|
||||
@@ -344,12 +603,94 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
}
|
||||
|
||||
setMessages(await aiStore.listMessages(sessionId));
|
||||
const updatedSuggestions = await aiStore.listSuggestions(sessionId);
|
||||
setSuggestions(updatedSuggestions);
|
||||
setSessions(await aiStore.listSessions());
|
||||
|
||||
if (parsedSuggestions.length > 0 && parsedSuggestions[0]?.lineNumber) {
|
||||
setHighlightedLine(parsedSuggestions[0].lineNumber);
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!input.trim() || sending) return;
|
||||
const text = input.trim();
|
||||
setInput("");
|
||||
await runPromptWithLineSuggestions(text);
|
||||
};
|
||||
|
||||
const handleGenerateLineSuggestions = async () => {
|
||||
await runPromptWithLineSuggestions(
|
||||
`Review ${activeChapter?.title ?? "this chapter"} line by line and suggest concrete line edits with line numbers.`,
|
||||
);
|
||||
setActiveTab("suggestions");
|
||||
setFocusedIndex(0);
|
||||
};
|
||||
|
||||
// Accept: applies the edit to the line on the writing screen and removes the suggestion from pending
|
||||
const handleAcceptSuggestion = async (sg: AISuggestion) => {
|
||||
if (!aiStore || !activeSessionId) return;
|
||||
const replacement = (draftEdits[sg.id] ?? sg.newText ?? sg.text).trim();
|
||||
if (!replacement) return;
|
||||
|
||||
const applied = await applyLineEdit({
|
||||
...(sg.chapterId ? { chapterId: sg.chapterId } : {}),
|
||||
...(sg.lineNumber != null ? { lineNumber: sg.lineNumber } : {}),
|
||||
...(sg.originalText ? { originalText: sg.originalText } : {}),
|
||||
newText: replacement,
|
||||
});
|
||||
|
||||
await aiStore.deleteSuggestion(activeSessionId, sg.id);
|
||||
const nextList = await aiStore.listSuggestions(activeSessionId);
|
||||
setSuggestions(nextList);
|
||||
|
||||
if (applied) {
|
||||
toast.success(`Accepted edit on Line ${sg.lineNumber ?? 1}`);
|
||||
} else {
|
||||
toast.info("Suggestion accepted");
|
||||
}
|
||||
};
|
||||
|
||||
// Deny: removes the suggestion from the suggestions list
|
||||
const handleDenySuggestion = async (sg: AISuggestion) => {
|
||||
if (!aiStore || !activeSessionId) return;
|
||||
await aiStore.deleteSuggestion(activeSessionId, sg.id);
|
||||
const nextList = await aiStore.listSuggestions(activeSessionId);
|
||||
setSuggestions(nextList);
|
||||
if (highlightedLine === sg.lineNumber) {
|
||||
setHighlightedLine(null);
|
||||
}
|
||||
toast.info(`Denied suggestion for Line ${sg.lineNumber ?? "?"}`);
|
||||
};
|
||||
|
||||
// Copy to Note: places the suggestion into the Notes section of the chat
|
||||
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}` : "",
|
||||
`Rationale: ${sg.summary}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
await aiStore.addNote(activeSessionId, {
|
||||
sessionId: activeSessionId,
|
||||
kind: "general",
|
||||
title: `Line ${sg.lineNumber ?? "?"}: ${sg.title}`,
|
||||
body: noteBody,
|
||||
status: "draft",
|
||||
});
|
||||
setNotes(await aiStore.listNotes(activeSessionId));
|
||||
toast.success(`Copied Line ${sg.lineNumber ?? "?"} suggestion to Notes`);
|
||||
};
|
||||
|
||||
const handleAddNote = async () => {
|
||||
if (!aiStore || !activeSessionId || !newNoteBody.trim()) return;
|
||||
await aiStore.addNote(activeSessionId, {
|
||||
@@ -362,10 +703,37 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
setNotes(await aiStore.listNotes(activeSessionId));
|
||||
};
|
||||
|
||||
const handleResolveSuggestion = async (suggestionId: string, accepted: boolean) => {
|
||||
if (!aiStore || !activeSessionId) return;
|
||||
await aiStore.resolveSuggestion(activeSessionId, suggestionId, accepted);
|
||||
setSuggestions(await aiStore.listSuggestions(activeSessionId));
|
||||
const handleHighlightLine = (lineNum: number | undefined) => {
|
||||
if (!lineNum) return;
|
||||
if (highlightedLine === lineNum) {
|
||||
setHighlightedLine(null);
|
||||
} else {
|
||||
setHighlightedLine(lineNum);
|
||||
}
|
||||
};
|
||||
|
||||
// Render message text with clickable [Line N] badges that highlight that line in the editor
|
||||
const renderMessageWithLineLinks = (content: string) => {
|
||||
const parts = content.split(/(\[Line\s+\d+\])/gi);
|
||||
return parts.map((part, i) => {
|
||||
const m = part.match(/^\[Line\s+(\d+)\]$/i);
|
||||
if (m) {
|
||||
const num = parseInt(m[1]!, 10);
|
||||
const isHighlighted = highlightedLine === num;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={`ps-ai-line-pill${isHighlighted ? " is-active" : ""}`}
|
||||
title={`Click to highlight Line ${num} on the writing screen`}
|
||||
onClick={() => setHighlightedLine(num)}
|
||||
>
|
||||
Line {num}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -376,6 +744,16 @@ 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 && (
|
||||
<button
|
||||
type="button"
|
||||
className="ps-ai-line-pill is-active"
|
||||
title="Click to clear line highlight on the writing screen"
|
||||
onClick={() => setHighlightedLine(null)}
|
||||
>
|
||||
Line {highlightedLine} ×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<button
|
||||
@@ -464,7 +842,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
|
||||
<div className="ps-ai-control-row">
|
||||
<label className="ps-ai-control-field" style={{ flex: 1 }}>
|
||||
<span>Agent Role</span>
|
||||
<span>Agent Role (Line-Number Aware)</span>
|
||||
<select
|
||||
className="ps-select ps-select-sm"
|
||||
value={activeAgent.id}
|
||||
@@ -584,10 +962,8 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
onClick={() => setActiveTab("suggestions")}
|
||||
>
|
||||
Suggestions
|
||||
{suggestions.filter((x) => x.status === "pending").length > 0 && (
|
||||
<span className="ps-tab-badge">
|
||||
{suggestions.filter((x) => x.status === "pending").length}
|
||||
</span>
|
||||
{pendingSuggestions.length > 0 && (
|
||||
<span className="ps-tab-badge">{pendingSuggestions.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
@@ -600,7 +976,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{/* CHAT TAB */}
|
||||
{activeTab === "chat" && (
|
||||
<div className="ps-ai-chat-body">
|
||||
<div ref={scrollRef} className="ps-ai-messages">
|
||||
@@ -608,11 +984,19 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
<div className="ps-ai-empty">
|
||||
<div style={{ fontSize: 24, marginBottom: 6 }}>✨</div>
|
||||
<div style={{ fontWeight: 600, color: "var(--ps-text)" }}>
|
||||
{activeAgent.name} Ready
|
||||
{activeAgent.name} Ready ({chapterLines.length} lines in chapter)
|
||||
</div>
|
||||
<p style={{ margin: "4px 0 0", fontSize: 12 }}>
|
||||
Ask {activeAgent.name} to critique your chapter, brainstorm plot turns, sharpen dialogue, or check canon consistency.
|
||||
<p style={{ margin: "6px 0 12px", fontSize: 12 }}>
|
||||
Prompts include 1-based line numbers (<code>[Line 1]</code>–<code>[Line {Math.max(1, chapterLines.length)}]</code>). Ask about any line or generate line-by-line suggestions.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void handleGenerateLineSuggestions()}
|
||||
disabled={sending}
|
||||
>
|
||||
✨ Suggest Line Edits for Chapter
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((m) => (
|
||||
@@ -623,7 +1007,9 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
<div className="ps-ai-msg-role">
|
||||
{m.role === "user" ? "You" : activeAgent.name}
|
||||
</div>
|
||||
<div className="ps-ai-msg-text">{m.content}</div>
|
||||
<div className="ps-ai-msg-text">
|
||||
{renderMessageWithLineLinks(m.content)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -633,7 +1019,7 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
<textarea
|
||||
className="ps-input ps-ai-input"
|
||||
rows={2}
|
||||
placeholder={`Message ${activeAgent.name} (${activeModel})…`}
|
||||
placeholder={`Ask ${activeAgent.name} about Line 1–${Math.max(1, chapterLines.length)} or request edits…`}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -643,17 +1029,28 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
|
||||
{activeSession && (
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="ps-btn ps-btn-ghost ps-btn-sm"
|
||||
onClick={() => void handleToggleArchiveSession(activeSession)}
|
||||
title={activeSession.archived ? "Restore this chat from archive" : "Archive this chat"}
|
||||
className="ps-btn ps-btn-secondary ps-btn-sm"
|
||||
onClick={() => void handleGenerateLineSuggestions()}
|
||||
disabled={sending}
|
||||
title="Analyze current chapter lines and populate Suggestions tab"
|
||||
>
|
||||
{activeSession.archived ? "📤 Unarchive" : "📦 Archive chat"}
|
||||
✨ Suggest Line Edits
|
||||
</button>
|
||||
)}
|
||||
{activeSession && (
|
||||
<button
|
||||
type="button"
|
||||
className="ps-btn ps-btn-ghost ps-btn-sm"
|
||||
onClick={() => void handleToggleArchiveSession(activeSession)}
|
||||
title={activeSession.archived ? "Restore this chat from archive" : "Archive this chat"}
|
||||
>
|
||||
{activeSession.archived ? "📤" : "📦"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
@@ -667,59 +1064,178 @@ export function AISidebar({ fullPage = false }: AISidebarProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SUGGESTIONS TAB: Step through each line suggestion with Accept / Deny / Copy to Note */}
|
||||
{activeTab === "suggestions" && (
|
||||
<div className="ps-ai-messages">
|
||||
{suggestions.length === 0 ? (
|
||||
<div className="ps-ai-empty">
|
||||
No inline suggestions in this session yet.
|
||||
<div className="ps-ai-chat-body">
|
||||
{pendingSuggestions.length > 0 && (
|
||||
<div className="ps-ai-stepper">
|
||||
<button
|
||||
type="button"
|
||||
className="ps-btn ps-btn-secondary ps-btn-sm"
|
||||
disabled={focusedIndex <= 0}
|
||||
onClick={() => {
|
||||
const nextIdx = Math.max(0, focusedIndex - 1);
|
||||
setFocusedIndex(nextIdx);
|
||||
const target = pendingSuggestions[nextIdx];
|
||||
if (target?.lineNumber) setHighlightedLine(target.lineNumber);
|
||||
}}
|
||||
>
|
||||
◀ Prev
|
||||
</button>
|
||||
<span style={{ fontSize: 12, fontWeight: 600 }}>
|
||||
Suggestion {focusedIndex + 1} of {pendingSuggestions.length}
|
||||
{focusedSuggestion?.lineNumber ? ` · Line ${focusedSuggestion.lineNumber}` : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ps-btn ps-btn-secondary ps-btn-sm"
|
||||
disabled={focusedIndex >= pendingSuggestions.length - 1}
|
||||
onClick={() => {
|
||||
const nextIdx = Math.min(pendingSuggestions.length - 1, focusedIndex + 1);
|
||||
setFocusedIndex(nextIdx);
|
||||
const target = pendingSuggestions[nextIdx];
|
||||
if (target?.lineNumber) setHighlightedLine(target.lineNumber);
|
||||
}}
|
||||
>
|
||||
Next ▶
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
suggestions.map((sg) => (
|
||||
<div key={sg.id} className="ps-ai-card">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4 }}>
|
||||
<Badge variant="accent">{sg.kind}</Badge>
|
||||
<Badge variant="neutral">{sg.status}</Badge>
|
||||
</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{sg.title}</div>
|
||||
<p style={{ fontSize: 12, color: "var(--ps-text-muted)", margin: "4px 0" }}>
|
||||
{sg.summary}
|
||||
</p>
|
||||
{sg.status === "pending" && (
|
||||
<div style={{ display: "flex", gap: 6, marginTop: 8 }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void handleResolveSuggestion(sg.id, true)}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => void handleResolveSuggestion(sg.id, false)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="ps-ai-messages">
|
||||
{pendingSuggestions.length === 0 ? (
|
||||
<div className="ps-ai-empty">
|
||||
<div style={{ marginBottom: 8 }}>No pending line suggestions yet.</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void handleGenerateLineSuggestions()}
|
||||
disabled={sending}
|
||||
>
|
||||
{sending ? "Analyzing Lines…" : "✨ Generate Line Suggestions"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
pendingSuggestions.map((sg, idx) => {
|
||||
const isFocused = idx === focusedIndex;
|
||||
const isLineHighlighted =
|
||||
sg.lineNumber != null && highlightedLine === sg.lineNumber;
|
||||
const currentReplacement =
|
||||
draftEdits[sg.id] ?? sg.newText ?? sg.text;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sg.id}
|
||||
className={`ps-ai-card ps-ai-suggestion-card${isFocused ? " is-focused" : ""}`}
|
||||
onClick={() => {
|
||||
setFocusedIndex(idx);
|
||||
if (sg.lineNumber) setHighlightedLine(sg.lineNumber);
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 6, marginBottom: 6 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`ps-ai-line-pill${isLineHighlighted ? " is-active" : ""}`}
|
||||
title="Click to highlight this line on the writing screen"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHighlightLine(sg.lineNumber ?? 1);
|
||||
}}
|
||||
>
|
||||
Line {sg.lineNumber ?? 1}
|
||||
</button>
|
||||
<strong style={{ fontSize: 12.5 }}>{sg.title}</strong>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: "var(--ps-text-muted)" }}>
|
||||
#{idx + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 12, color: "var(--ps-text-muted)", margin: "0 0 8px" }}>
|
||||
{sg.summary}
|
||||
</p>
|
||||
|
||||
{sg.originalText && (
|
||||
<div className="ps-ai-diff-original">
|
||||
<span className="ps-ai-diff-label">Original (Line {sg.lineNumber ?? 1}):</span>
|
||||
<div>{sg.originalText}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ps-ai-diff-new">
|
||||
<span className="ps-ai-diff-label">Suggested Edit:</span>
|
||||
<textarea
|
||||
className="ps-input ps-ai-input"
|
||||
rows={3}
|
||||
value={currentReplacement}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) =>
|
||||
setDraftEdits((prev) => ({
|
||||
...prev,
|
||||
[sg.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="ps-ai-suggestion-actions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
title="Apply this edit directly to the line on the writing screen"
|
||||
onClick={() => void handleAcceptSuggestion(sg)}
|
||||
>
|
||||
✓ Accept
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
title="Remove this suggestion"
|
||||
onClick={() => void handleDenySuggestion(sg)}
|
||||
>
|
||||
✕ Deny
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
title="Copy this suggestion to the Notes section of the chat"
|
||||
onClick={() => void handleCopySuggestionToNote(sg)}
|
||||
>
|
||||
📋 Copy to Note
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NOTES TAB */}
|
||||
{activeTab === "notes" && (
|
||||
<div className="ps-ai-chat-body">
|
||||
<div className="ps-ai-messages">
|
||||
{notes.length === 0 ? (
|
||||
<div className="ps-ai-empty">No session notes yet.</div>
|
||||
<div className="ps-ai-empty">
|
||||
No session notes yet. Click “Copy to Note” on any suggestion or add a note below.
|
||||
</div>
|
||||
) : (
|
||||
notes.map((n) => (
|
||||
<div key={n.id} className="ps-ai-card">
|
||||
<Badge variant="neutral">{n.kind}</Badge>
|
||||
<p style={{ margin: "6px 0 0", fontSize: 13, whiteSpace: "pre-wrap" }}>
|
||||
{n.body}
|
||||
</p>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
|
||||
<Badge variant="neutral">{n.kind}</Badge>
|
||||
{n.title && (
|
||||
<strong style={{ fontSize: 12 }}>{n.title}</strong>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ margin: "6px 0 0", fontSize: 13, whiteSpace: "pre-wrap" }}>
|
||||
{renderMessageWithLineLinks(n.body)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user