feat: use enlarged single PS logo and add line-numbered AI suggestions with highlight/accept/deny/copy-to-note

This commit is contained in:
Schwitzz
2026-10-30 11:41:00 -05:00
parent 4add429469
commit b1c22229c5
9 changed files with 903 additions and 169 deletions
+623 -107
View File
@@ -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>
))
)}