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
+5 -20
View File
@@ -57,26 +57,11 @@ export function Sidebar({ nav }: SidebarProps) {
style={{ cursor: "pointer" }}
title="PubScript"
>
{navCollapsed ? (
<img
src="/logo.svg"
alt="PubScript"
className="ps-sidebar-logo-icon"
/>
) : (
<div className="ps-sidebar-brand">
<img
src="/logo.svg"
alt="PubScript"
className="ps-sidebar-logo-img"
/>
<img
src="/logo-lockup.svg"
alt="PubScript"
className="ps-sidebar-logo-lockup"
/>
</div>
)}
<img
src="/logo.svg"
alt="PubScript"
className="ps-sidebar-logo-only"
/>
</div>
<button
type="button"
+28 -3
View File
@@ -79,6 +79,8 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
const flushAutosave = useProjectStore((s) => s.flushAutosave);
const autosave = useProjectStore((s) => s.autosave);
const entities = useProjectStore((s) => s.entities);
const highlightedLine = useProjectStore((s) => s.highlightedLine);
const docExternalRev = useProjectStore((s) => s.docExternalRev);
const fontFamily = useSettings((st) => st.fontFamily);
const fontSize = useSettings((st) => st.fontSize);
@@ -129,12 +131,35 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
const check = useCheckService({ editor });
// Sync content when the active chapter changes.
// Sync content when the active chapter changes or when an AI suggestion edit is applied.
useEffect(() => {
if (!editor || !chapter) return;
editor.commands.setContent(chapter.doc, { emitUpdate: false });
editor.commands.focus();
}, [editor, chapter?.id]);
}, [editor, chapter?.id, docExternalRev]);
// Highlight a specific 1-based line block in the editor when an AI suggestion targets it.
useEffect(() => {
const editorDom = editorDomRef.current;
if (!editorDom) return undefined;
const pmRoot = editorDom.querySelector(".ProseMirror");
if (!pmRoot) return undefined;
const children = Array.from(pmRoot.children) as HTMLElement[];
children.forEach((el) => el.classList.remove("ps-ai-line-highlight"));
if (highlightedLine != null && highlightedLine >= 1 && highlightedLine <= children.length) {
const targetEl = children[highlightedLine - 1];
if (targetEl) {
targetEl.classList.add("ps-ai-line-highlight");
targetEl.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
return () => {
children.forEach((el) => el.classList.remove("ps-ai-line-highlight"));
};
}, [highlightedLine, docExternalRev, chapter?.id]);
// Focus mode: dim every top-level block in .ProseMirror except the one
// containing the active cursor selection.
@@ -831,7 +856,7 @@ export function WritingView({ onChapterChange }: WritingViewProps) {
{/* Main Editor Body + Context Rail */}
<div className="ps-writing-body">
<div
className={`ps-editor-scroll${focusMode ? " ps-editor-focus" : ""}${showLineNumbers ? " ps-editor-linenumbers" : ""}${firstLineIndent ? " ps-editor-indent" : ""}`}
className={`ps-editor-scroll${focusMode ? " ps-editor-focus" : ""}${showLineNumbers || highlightedLine != null ? " ps-editor-linenumbers" : ""}${firstLineIndent ? " ps-editor-indent" : ""}`}
>
<div className="ps-editor-writing">
<div className="ps-editor" ref={editorDomRef}>
+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>
))
)}
+106
View File
@@ -69,6 +69,8 @@ interface ProjectStore {
activeBookId: string | null;
chapters: Chapter[];
activeChapterId: string | null;
highlightedLine: number | null;
docExternalRev: number;
status: "idle" | "loading" | "ready";
error: string | null;
autosave: AutosaveState;
@@ -109,6 +111,15 @@ interface ProjectStore {
scheduleSave: (ref: DocumentRef) => void;
/** Flush any pending save immediately (beforeunload / chapter switch). */
flushAutosave: () => void;
/** Highlight a 1-based line number on the writing surface (or null to clear). */
setHighlightedLine: (line: number | null) => void;
/** Apply an AI suggestion edit to a 1-based line number in the active chapter. */
applyLineEdit: (opts: {
chapterId?: string;
lineNumber?: number;
originalText?: string;
newText: string;
}) => Promise<boolean>;
// --- entity actions ---
loadEntities: (kind?: string) => Promise<void>;
@@ -192,6 +203,8 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
activeBookId: null,
chapters: [],
activeChapterId: null,
highlightedLine: null,
docExternalRev: 0,
status: "idle",
error: null,
autosave: { dirty: false, saving: false, lastSavedAt: null, status: "saved" },
@@ -524,6 +537,99 @@ export const useProjectStore = create<ProjectStore>((set, get) => {
}
},
setHighlightedLine: (line) => {
set({ highlightedLine: line });
},
applyLineEdit: async ({ chapterId, lineNumber, originalText, newText }) => {
const { adapter, project, chapters, activeChapterId } = get();
if (!adapter || !project) return false;
const targetId = chapterId ?? activeChapterId ?? chapters[0]?.id;
const targetChapter = chapters.find((c) => c.id === targetId);
if (!targetChapter) return false;
const blocks = Array.isArray(targetChapter.doc.content)
? targetChapter.doc.content.map((b) => ({ ...b }))
: [];
if (blocks.length === 0) return false;
const extractBlockText = (b: Record<string, unknown>): string => {
const parts: string[] = [];
const walk = (n: Record<string, unknown>) => {
if (typeof n.text === "string") parts.push(n.text);
if (Array.isArray(n.content)) {
for (const c of n.content) {
if (c && typeof c === "object") walk(c as Record<string, unknown>);
}
}
};
walk(b);
return parts.join("");
};
let targetIndex =
lineNumber != null && lineNumber >= 1 && lineNumber <= blocks.length
? lineNumber - 1
: -1;
if (originalText && originalText.trim()) {
const needle = originalText.trim();
if (
targetIndex === -1 ||
!extractBlockText(blocks[targetIndex]! as unknown as Record<string, unknown>).includes(needle)
) {
const foundIdx = blocks.findIndex((b) =>
extractBlockText(b as unknown as Record<string, unknown>).includes(needle),
);
if (foundIdx !== -1) targetIndex = foundIdx;
}
}
if (targetIndex === -1) {
targetIndex = 0;
}
const oldBlock = blocks[targetIndex]! as unknown as Record<string, unknown>;
const oldFullText = extractBlockText(oldBlock);
const updatedLineText =
originalText &&
originalText.trim() &&
oldFullText.includes(originalText.trim()) &&
oldFullText.trim() !== originalText.trim()
? oldFullText.replace(originalText.trim(), newText)
: newText;
blocks[targetIndex] = {
...blocks[targetIndex]!,
type: (oldBlock.type as string) || "paragraph",
content: [{ type: "text", text: updatedLineText }],
};
const nextDoc = {
...targetChapter.doc,
content: blocks,
};
await adapter.saveDocument({
id: targetChapter.id,
projectId: project.id,
bookId: targetChapter.bookId,
title: targetChapter.title,
doc: nextDoc,
rev: targetChapter.rev,
});
set((state) => ({
activeChapterId: targetChapter.id,
highlightedLine: targetIndex + 1,
docExternalRev: state.docExternalRev + 1,
chapters: state.chapters.map((c) =>
c.id === targetChapter.id ? { ...c, doc: nextDoc, rev: c.rev + 1 } : c,
),
}));
return true;
},
loadEntities: async (kind) => {
const { adapter, project } = get();
if (!adapter || !project) return;
+20 -12
View File
@@ -185,6 +185,9 @@ export const DEFAULT_AI_PROVIDERS: AIProviderSetting[] = [
},
];
export const LINE_SUGGESTION_PROMPT_RULES =
"IMPORTANT LINE-NUMBER PROTOCOL: The active chapter is provided with 1-based line numbers ([Line 1], [Line 2], …) matching the writing editor gutter. Always cite exact line numbers (e.g. [Line 2]) in your response. Whenever you suggest edits, rewrites, or improvements to specific lines, also emit one or more structured suggestion blocks at the end of your reply in this exact format so the author can highlight the line on screen and click Accept, Deny, or Copy to Note:\n<<<SUGGESTION\nLINE: <line_number>\nTITLE: <short label>\nORIGINAL: <exact original text of that line>\nREPLACEMENT: <your revised replacement text for that line>\nSUMMARY: <1-sentence rationale>\nSUGGESTION>>>";
export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
{
id: "editor",
@@ -195,8 +198,7 @@ export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
maxTokens: 2048,
thinkingEnabled: false,
thinkingEffort: "medium",
systemPrompt:
"You are the PubScript Line Editor. Tighten prose, eliminate filter words, sharpen dialogue tags, improve pacing, flag weak openings or closings, and verify consistency against character and world sheets. Always preserve the author's distinct voice and explain your rationale.",
systemPrompt: `You are the PubScript Line Editor. Tighten prose, eliminate filter words, sharpen dialogue tags, improve pacing, flag weak openings or closings, and verify consistency against character and world sheets while preserving the author's distinct voice.\n\n${LINE_SUGGESTION_PROMPT_RULES}`,
},
{
id: "reader",
@@ -207,8 +209,7 @@ export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
maxTokens: 2048,
thinkingEnabled: false,
thinkingEffort: "medium",
systemPrompt:
"You are an honest, attentive first-time reader. Report what felt vivid, where your attention drifted, what was confusing, what was telegraphed too early, and what emotional beats paid off. Speak as an engaged reader rather than using dry technical jargon.",
systemPrompt: `You are an honest, attentive first-time reader. Report what felt vivid, where your attention drifted, what was confusing, what was telegraphed too early, and what emotional beats paid off by citing exact [Line N] numbers.\n\n${LINE_SUGGESTION_PROMPT_RULES}`,
},
{
id: "publisher",
@@ -219,8 +220,7 @@ export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
maxTokens: 2048,
thinkingEnabled: false,
thinkingEffort: "medium",
systemPrompt:
"You are a literary Publisher and developmental strategist. Evaluate commercial hook, genre pacing, comparable titles (comps), blurb/pitch angles, audience expectations, and structural stakes across acts.",
systemPrompt: `You are a literary Publisher and developmental strategist. Evaluate commercial hook, genre pacing, comparable titles (comps), blurb/pitch angles, audience expectations, and structural stakes across acts, referencing specific [Line N] passages.\n\n${LINE_SUGGESTION_PROMPT_RULES}`,
},
{
id: "co-author",
@@ -231,8 +231,7 @@ export const DEFAULT_AI_AGENTS: AIAgentConfig[] = [
maxTokens: 4096,
thinkingEnabled: true,
thinkingEffort: "medium",
systemPrompt:
"You are a collaborative Co-Author with full knowledge of the manuscript's characters, world-building, and plot beats. Brainstorm plot turns, draft scenes or dialogue in the author's established voice, and expand world lore—always as adoptable suggestions that respect the story's canon.",
systemPrompt: `You are a collaborative Co-Author with full knowledge of the manuscript's characters, world-building, and plot beats. Brainstorm plot turns, draft or revise specific [Line N] passages in the author's established voice, and expand world lore as adoptable line suggestions.\n\n${LINE_SUGGESTION_PROMPT_RULES}`,
},
];
@@ -300,6 +299,18 @@ function loadSettings(): AppSettings {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_SETTINGS };
const parsed = JSON.parse(raw) as Partial<AppSettings>;
const rawAgents =
Array.isArray(parsed.aiAgents) && parsed.aiAgents.length > 0
? parsed.aiAgents
: DEFAULT_AI_AGENTS;
// Upgrade any stored built-in agent whose prompt predates the line-number protocol.
const aiAgents = rawAgents.map((agent) => {
const def = DEFAULT_AI_AGENTS.find((d) => d.id === agent.id);
if (def && !agent.systemPrompt.includes("LINE-NUMBER PROTOCOL") && !agent.systemPrompt.includes("<<<SUGGESTION")) {
return { ...agent, systemPrompt: def.systemPrompt };
}
return agent;
});
return {
...DEFAULT_SETTINGS,
...parsed,
@@ -307,10 +318,7 @@ function loadSettings(): AppSettings {
Array.isArray(parsed.aiProviders) && parsed.aiProviders.length > 0
? parsed.aiProviders
: DEFAULT_AI_PROVIDERS,
aiAgents:
Array.isArray(parsed.aiAgents) && parsed.aiAgents.length > 0
? parsed.aiAgents
: DEFAULT_AI_AGENTS,
aiAgents,
};
} catch {
return { ...DEFAULT_SETTINGS };
+14
View File
@@ -246,6 +246,20 @@
transition: opacity 0.08s ease;
}
/* AI suggestion line highlight on the writing canvas */
.ProseMirror > .ps-ai-line-highlight {
background: color-mix(in srgb, var(--ps-accent) 20%, transparent);
outline: 2px solid var(--ps-accent);
outline-offset: 4px;
border-radius: 6px;
transition: background 0.2s ease, outline 0.2s ease;
}
.ps-editor-linenumbers .ProseMirror > .ps-ai-line-highlight::before {
color: var(--ps-accent);
font-weight: 700;
opacity: 1;
}
/* Status bar */
.ps-writing-status {
display: flex;
+10
View File
@@ -318,6 +318,16 @@ export class AIStore {
});
}
/** Remove a suggestion from a session (used when denying or dismissing). */
async deleteSuggestion(sessionId: string, suggestionId: string): Promise<AISession> {
return this.withTransaction(async () => {
const session = this.require(sessionId);
session.suggestions = session.suggestions.filter((s) => s.id !== suggestionId);
session.updatedAt = nowIso();
return this.clone(session);
});
}
/** Add a note to a session and persist it. */
async addNote(sessionId: string, note: Omit<AINote, "id" | "createdAt" | "updatedAt">): Promise<AISession> {
return this.withTransaction(async () => {
+9 -7
View File
@@ -299,14 +299,16 @@ export interface AISuggestion {
title: string;
text: string;
summary: string;
originalText?: string;
newText?: string;
anchorIndex?: number;
spanLength?: number;
lineNumber?: number | undefined;
chapterId?: string | undefined;
originalText?: string | undefined;
newText?: string | undefined;
anchorIndex?: number | undefined;
spanLength?: number | undefined;
status: AISuggestionStatus;
appliedAt?: string;
resolvedAt?: string;
resolution?: "accepted" | "rejected";
appliedAt?: string | undefined;
resolvedAt?: string | undefined;
resolution?: "accepted" | "rejected" | undefined;
}
/** An AI-backed session with its messages, suggestions, and notes. */
+88 -20
View File
@@ -143,27 +143,18 @@ body {
gap: 6px;
}
.ps-sidebar-brand {
display: flex;
align-items: center;
gap: 8px;
}
.ps-sidebar-logo-img {
width: 30px;
height: 30px;
border-radius: 6px;
flex-shrink: 0;
}
.ps-sidebar-logo-lockup {
height: 36px;
width: auto;
object-fit: contain;
}
.ps-sidebar-logo-icon {
width: 32px;
height: 32px;
border-radius: 7px;
.ps-sidebar-logo-only {
width: 68px;
height: 68px;
border-radius: 12px;
display: block;
flex-shrink: 0;
transition: width 0.16s ease, height 0.16s ease;
}
.ps-sidebar.is-collapsed .ps-sidebar-logo-only {
width: 44px;
height: 44px;
border-radius: 9px;
}
.ps-sidebar-toggle {
@@ -840,6 +831,83 @@ body {
border-radius: 8px;
padding: 10px 12px;
}
.ps-ai-suggestion-card {
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.ps-ai-suggestion-card:hover {
border-color: color-mix(in srgb, var(--ps-accent) 60%, var(--ps-border));
}
.ps-ai-suggestion-card.is-focused {
border-color: var(--ps-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ps-accent) 28%, transparent);
}
.ps-ai-stepper {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--ps-border);
background: var(--ps-surface-2);
}
.ps-ai-line-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 7px;
margin: 0 2px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--ps-accent) 50%, var(--ps-border));
background: color-mix(in srgb, var(--ps-accent) 16%, transparent);
color: var(--ps-accent);
font-family: var(--ps-font-mono);
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.ps-ai-line-pill:hover,
.ps-ai-line-pill.is-active {
background: var(--ps-accent);
color: var(--ps-accent-contrast);
border-color: var(--ps-accent);
}
.ps-ai-diff-original {
font-size: 12px;
padding: 7px 9px;
border-radius: 6px;
background: color-mix(in srgb, var(--ps-danger) 12%, var(--ps-bg));
border-left: 3px solid var(--ps-danger);
margin-bottom: 8px;
color: var(--ps-text-muted);
}
.ps-ai-diff-new {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
padding: 7px 9px;
border-radius: 6px;
background: color-mix(in srgb, var(--ps-success) 10%, var(--ps-bg));
border-left: 3px solid var(--ps-success);
margin-bottom: 10px;
}
.ps-ai-diff-label {
display: block;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ps-text-muted);
margin-bottom: 2px;
}
.ps-ai-suggestion-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
/* ---- Projects Page & Template Wizard ---- */
.ps-projects-grid {