AI editor that critiques your arguments, highlights weak spots, and helps you fix them.
import React, { useState, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import {
Plate,
PlateContent,
createPlateEditor,
toTPlatePlugin,
ParagraphPlugin,
} from "platejs/react";
import {
BlockquotePlugin,
BoldPlugin,
CodePlugin,
H1Plugin,
H2Plugin,
H3Plugin,
ItalicPlugin,
StrikethroughPlugin,
} from "@platejs/basic-nodes/react";
import { BaseSuggestionPlugin } from "@platejs/suggestion";
import { MarkdownPlugin } from "@platejs/markdown";
import { BulletedListPlugin, NumberedListPlugin, ListItemPlugin } from "@platejs/list-classic/react";
import { CodeBlockPlugin, CodeLinePlugin } from "@platejs/code-block/react";
import { LinkPlugin } from "@platejs/link/react";
import { TablePlugin, TableRowPlugin, TableCellPlugin, TableCellHeaderPlugin } from "@platejs/table/react";
// ============ Types ============
interface Model {
provider: string;
name: string;
friendlyName: string;
}
interface ChangeCard {
id: string;
insertText: string;
removeText: string;
rationale?: string;
}
interface ReviewItem {
id: string;
quote: string;
issue: string;
reply: string;
}
interface HighlightRegion {
start: number;
end: number;
itemId: string;
}
// ============ Suggestion Leaf ============
function SuggestionLeaf({ children, leaf, attributes }: any) {
const suggestionKey = Object.keys(leaf).find((k) =>
k.startsWith("suggestion_")
);
if (!suggestionKey || !leaf[suggestionKey]) {
return <span {...attributes}>{children}</span>;
}
const data = leaf[suggestionKey];
const isInsert = data.type === "insert";
const isRemove = data.type === "remove";
return (
<span
{...attributes}
className={isRemove ? "sg-remove" : isInsert ? "sg-insert" : ""}
data-suggestion-id={data.id}
data-suggestion-type={data.type}
>
{children}
</span>
);
}
function EditorLeaf(props: any) {
if (props.leaf.reviewHighlight) {
return <mark {...props.attributes} className="rv-hl" data-review-id={props.leaf.reviewId}>{props.children}</mark>;
}
return <SuggestionLeaf {...props} />;
}
function propagateBlockSuggestions(nodes: any[]): any[] {
return nodes.map((node) => {
if (!node.children) return node;
let children = propagateBlockSuggestions(node.children);
if (isBlockSuggestion(node)) {
const { id, type } = node.suggestion;
children = children.map((child: any) => {
if (child.text !== undefined) {
return { ...child, suggestion: true, [`suggestion_${id}`]: { id, type } };
}
if (child.children) {
return { ...child, children: child.children.map((leaf: any) =>
leaf.text !== undefined ? { ...leaf, suggestion: true, [`suggestion_${id}`]: { id, type } } : leaf
)};
}
return child;
});
}
return { ...node, children };
});
}
// ============ Suggestion Utilities ============
// Block-level suggestions store an object: { id, type, ... }
// Text-level suggestion marks store a boolean: suggestion: true
// plus suggestion_<id>: { id, type, ... } keys.
// These helpers must distinguish the two.
function isBlockSuggestion(node: any): boolean {
return (
node.children &&
typeof node.suggestion === "object" &&
node.suggestion !== null
);
}
function collectSuggestionIds(nodes: any[]): Set<string> {
const ids = new Set<string>();
function walk(node: any) {
if (isBlockSuggestion(node)) ids.add(node.suggestion.id);
if (node.children) node.children.forEach(walk);
for (const key of Object.keys(node)) {
if (key.startsWith("suggestion_") && node[key]?.id) {
ids.add(node[key].id);
}
}
}
nodes.forEach(walk);
return ids;
}
function resolveSuggestions(
nodes: any[],
accept: boolean,
targetId?: string
): any[] {
const dropType = accept ? "remove" : "insert";
return nodes.flatMap((node: any) => {
// Block-level suggestion (object on element nodes only)
if (
isBlockSuggestion(node) &&
(!targetId || node.suggestion.id === targetId)
) {
if (node.suggestion.type === dropType) return [];
const { suggestion, ...rest } = node;
return [
{
...rest,
children: resolveSuggestions(rest.children, accept, targetId),
},
];
}
// Element with children (no matching block suggestion)
if (node.children) {
const resolved = resolveSuggestions(node.children, accept, targetId);
return [
{
...node,
children: resolved.length > 0 ? resolved : [{ text: "" }],
},
];
}
// Text node — check for inline suggestion marks
const key = targetId
? `suggestion_${targetId}`
: Object.keys(node).find((k) => k.startsWith("suggestion_"));
if (!key || !node[key]) return [node];
if (node[key].type === dropType) return [];
const cleaned = { ...node };
delete cleaned.suggestion;
delete cleaned[key];
return [cleaned];
});
}
// ============ JSON Parsing ============
function sanitizeJson(content: string): unknown | null {
let json = content.trim();
// Strip markdown fences wrapping the whole output
if (json.startsWith("```")) {
json = json.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "");
}
let parsed: unknown | null = null;
try { parsed = JSON.parse(json); } catch {}
if (parsed !== null) return parsed;
// Fenced JSON after preamble text
const fenceMatch = json.match(/```(?:json)?\s*\n([\s\S]*?)\n?```\s*$/);
if (fenceMatch) {
json = fenceMatch[1].trim();
try { parsed = JSON.parse(json); } catch {}
if (parsed !== null) return parsed;
}
// Bare JSON after preamble text
if (!json.startsWith("{") && !json.startsWith("[")) {
const objIdx = json.indexOf("{");
const arrIdx = json.indexOf("[");
const idx =
objIdx >= 0 && arrIdx >= 0
? Math.min(objIdx, arrIdx)
: objIdx >= 0
? objIdx
: arrIdx;
if (idx > 0) {
json = json.slice(idx);
try { parsed = JSON.parse(json); } catch {}
if (parsed !== null) return parsed;
}
}
// Trailing fixes
const fixes: Array<(s: string) => string | null> = [
(s) => s.endsWith("}}") ? s.slice(0, -1) : null,
(s) => s.endsWith("]]") ? s.slice(0, -1) : null,
(s) => (s.match(/,\s*\}$/) ? s.replace(/,\s*\}$/, "}") : null),
(s) => (s.match(/,\s*\]$/) ? s.replace(/,\s*\]$/, "]") : null),
];
for (const fix of fixes) {
const fixed = fix(json);
if (fixed !== null) {
try { parsed = JSON.parse(fixed); } catch {}
if (parsed !== null) return parsed;
}
}
return null;
}
function genId(): string {
return Math.random().toString(36).slice(2, 10);
}
function extractText(node: any): string {
if (node.text !== undefined) return node.text;
if (node.children) return node.children.map(extractText).join("");
return "";
}
function blockToPlainText(node: any): string {
if (node.type === "ul" || node.type === "ol") {
return node.children?.map((li: any) => extractText(li)).join("\n") || "";
}
if (node.type === "code_block") {
return node.children?.map((line: any) => extractText(line)).join("\n") || "";
}
if (node.type === "table") {
return node.children?.map((tr: any) =>
tr.children?.map((td: any) => extractText(td)).join(" | ") || ""
).join("\n") || "";
}
if (node.type === "tr") {
return node.children?.map((td: any) => extractText(td)).join(" | ") || "";
}
return extractText(node);
}
function editorToPlainText(editor: any): string {
return editor.children.map((node: any) => blockToPlainText(node)).join("\n\n");
}
function flexPattern(quote: string): RegExp {
const escaped = quote
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
.replace(/['"\u2018\u2019\u201A\u201C\u201D\u201E]/g, "['\"\u2018\u2019\u201A\u201C\u201D\u201E]")
.replace(/[-\u2013\u2014]/g, "[-\u2013\u2014]")
.replace(/\\.{3}/g, "(?:\\.{3}|\u2026)")
.replace(/\s+/g, "\\s+");
return new RegExp(escaped, "i");
}
function fuzzyFind(text: string, quote: string): { start: number; end: number } | null {
if (!quote) return null;
// Exact match in original text
let idx = text.indexOf(quote);
if (idx !== -1) return { start: idx, end: idx + quote.length };
// Case-insensitive
idx = text.toLowerCase().indexOf(quote.toLowerCase());
if (idx !== -1) return { start: idx, end: idx + quote.length };
// Flexible match (quotes, dashes, whitespace variants) — still in original-text space
const m = text.match(flexPattern(quote));
if (m && m.index !== undefined) return { start: m.index, end: m.index + m[0].length };
// Prefix fallback (only for longer quotes)
if (quote.trim().length < 5) return null;
const minLen = Math.min(30, quote.length);
for (let len = quote.length - 1; len >= minLen; len--) {
const pm = text.match(flexPattern(quote.substring(0, len)));
if (pm && pm.index !== undefined) {
return { start: pm.index, end: Math.min(text.length, pm.index + quote.length) };
}
}
return null;
}
function buildReviewRegions(text: string, items: ReviewItem[]): HighlightRegion[] {
const regions: HighlightRegion[] = [];
for (const item of items) {
if (!item.quote) continue;
const match = fuzzyFind(text, item.quote);
if (match) regions.push({ start: match.start, end: match.end, itemId: item.id });
}
regions.sort((a, b) => a.start - b.start);
return regions;
}
// Shared leaf-splitting: locates a text range in a block's children,
// splits leaves at boundaries, and calls markLeaf for each segment.
// appendNodes optionally adds extra nodes after the marked range (e.g. suggestion inserts).
// parentType is used to determine separators between compound block children.
function splitAndMarkLeaves(
children: any[],
start: number,
end: number,
markLeaf: (marks: Record<string, any>, text: string) => any,
appendNodes?: (firstMarks: Record<string, any>) => any[],
parentType?: string
) {
// If children are elements (not text leaves), recurse into them with adjusted offsets
let childOffset = 0;
let hasTextLeaves = false;
for (const child of children) {
if (child.text !== undefined) { hasTextLeaves = true; break; }
}
if (!hasTextLeaves) {
// Compound blocks use separators between children in plain text
const sep = (parentType === "ul" || parentType === "ol" || parentType === "code_block" || parentType === "table") ? 1
: parentType === "tr" ? 3 // " | "
: 0;
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (!child.children) continue;
const len = extractText(child).length;
if (start < childOffset + len && end > childOffset) {
splitAndMarkLeaves(child.children, start - childOffset, end - childOffset, markLeaf, appendNodes, child.type);
}
childOffset += len;
if (i < children.length - 1) childOffset += sep;
}
return;
}
const leaves: Array<{ leaf: any; start: number; end: number; idx: number }> = [];
let offset = 0;
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child.text !== undefined) {
leaves.push({ leaf: child, start: offset, end: offset + child.text.length, idx: i });
offset += child.text.length;
} else if (child.children) {
const len = extractText(child).length;
if (start < offset + len && end > offset) {
splitAndMarkLeaves(child.children, start - offset, end - offset, markLeaf, appendNodes, child.type);
}
offset += len;
}
}
const first = leaves.find((l) => l.end > start && l.start <= start);
const last = [...leaves].reverse().find((l) => l.start < end && l.end >= end);
if (!first || !last) return;
const newNodes: any[] = [];
if (first.idx === last.idx) {
const ls = start - first.start;
const le = end - first.start;
const { text, ...marks } = first.leaf;
if (ls > 0) newNodes.push({ ...marks, text: text.substring(0, ls) });
newNodes.push(markLeaf(marks, text.substring(ls, le)));
if (appendNodes) newNodes.push(...appendNodes(marks));
if (le < text.length) newNodes.push({ ...marks, text: text.substring(le) });
children.splice(first.idx, 1, ...newNodes);
} else {
const ls = start - first.start;
const le = end - last.start;
const { text: fText, ...fMarks } = first.leaf;
const { text: lText, ...lMarks } = last.leaf;
if (ls > 0) newNodes.push({ ...fMarks, text: fText.substring(0, ls) });
newNodes.push(markLeaf(fMarks, fText.substring(ls)));
for (const ml of leaves.filter((l) => l.idx > first.idx && l.idx < last.idx)) {
const { text: mText, ...mMarks } = children[ml.idx];
newNodes.push(markLeaf(mMarks, mText));
}
newNodes.push(markLeaf(lMarks, lText.substring(0, le)));
if (appendNodes) newNodes.push(...appendNodes(fMarks));
if (le < lText.length) newNodes.push({ ...lMarks, text: lText.substring(le) });
children.splice(first.idx, last.idx - first.idx + 1, ...newNodes);
}
}
function walkBlocksAndApply(
nodes: any[],
items: Array<{ start: number; end: number; [key: string]: any }>,
applyToBlock: (children: any[], localStart: number, localEnd: number, item: any, blockType: string) => void
): any[] {
const result = structuredClone(nodes);
const sorted = [...items].sort((a, b) => b.start - a.start);
for (const item of sorted) {
let blockOffset = 0;
for (const block of result) {
const blockText = blockToPlainText(block);
const blockEnd = blockOffset + blockText.length;
if (item.start >= blockOffset && item.start < blockEnd && block.children) {
const localStart = item.start - blockOffset;
const localEnd = Math.min(item.end - blockOffset, blockText.length);
applyToBlock(block.children, localStart, localEnd, item, block.type || "p");
break;
}
blockOffset = blockEnd + 2;
}
}
return result;
}
function addReviewMarks(nodes: any[], regions: HighlightRegion[]): any[] {
if (regions.length === 0) return nodes;
return walkBlocksAndApply(nodes, regions, (children, start, end, region, blockType) => {
splitAndMarkLeaves(children, start, end,
(marks, text) => ({ ...marks, text, reviewHighlight: true, reviewId: region.itemId }),
undefined, blockType
);
});
}
function stripReviewMarks(nodes: any[]): any[] {
return nodes.map((node) => {
if (node.children) {
return { ...node, children: stripReviewMarks(node.children) };
}
if (node.reviewHighlight) {
const { reviewHighlight, reviewId, ...rest } = node;
return rest;
}
return node;
});
}
function parseReviewResponse(text: string): Array<{ quote: string; issue: string }> {
const parsed = sanitizeJson(text) as any;
const arr = Array.isArray(parsed?.issues)
? parsed.issues
: Array.isArray(parsed)
? parsed
: null;
if (!arr) throw new Error("The AI didn't return usable analysis. Try again.");
return arr
.map((c: any) => ({
quote: c.quote || "",
issue: typeof c === "string" ? c : c.issue || c.critique || c.text || JSON.stringify(c),
}))
.filter((c: any) => c.quote && c.issue);
}
function normalizeText(s: string): string {
return s
.replace(/[\u2018\u2019\u201A]/g, "'")
.replace(/[\u201C\u201D\u201E]/g, '"')
.replace(/[\u2013\u2014]/g, "-")
.replace(/\u2026/g, "...")
.replace(/\s+/g, " ")
.trim();
}
function parseInlineMarkdown(text: string): any[] {
const result: any[] = [];
const markPatterns: Array<{ re: RegExp; mark: string }> = [
{ re: /\*\*(.+?)\*\*/, mark: "bold" },
{ re: /\*(.+?)\*/, mark: "italic" },
{ re: /`(.+?)`/, mark: "code" },
{ re: /~~(.+?)~~/, mark: "strikethrough" },
];
const linkRe = /\[([^\]]+)\]\(([^)]+)\)/;
let remaining = text;
while (remaining) {
let best: { index: number; len: number; inner: string; mark: string } | null = null;
for (const { re, mark } of markPatterns) {
const m = remaining.match(re);
if (m && m.index !== undefined && (!best || m.index < best.index)) {
best = { index: m.index, len: m[0].length, inner: m[1], mark };
}
}
// Check for link
const linkMatch = remaining.match(linkRe);
const linkIdx = linkMatch?.index ?? Infinity;
const bestIdx = best?.index ?? Infinity;
if (linkIdx < bestIdx && linkMatch) {
if (linkIdx > 0) result.push({ text: remaining.substring(0, linkIdx) });
result.push({ type: "a", url: linkMatch[2], children: [{ text: linkMatch[1] }] });
remaining = remaining.substring(linkIdx + linkMatch[0].length);
continue;
}
if (!best) { if (remaining) result.push({ text: remaining }); break; }
if (best.index > 0) result.push({ text: remaining.substring(0, best.index) });
result.push({ text: best.inner, [best.mark]: true });
remaining = remaining.substring(best.index + best.len);
}
return result.length > 0 ? result : [{ text }];
}
function applySuggestionReplacements(
nodes: any[],
replacements: Array<{ start: number; end: number; replace: string; id: string }>
): any[] {
return walkBlocksAndApply(nodes, replacements, (children, start, end, rep, blockType) => {
const sgKey = `suggestion_${rep.id}`;
splitAndMarkLeaves(children, start, end,
(marks, text) => ({ ...marks, text, suggestion: true, [sgKey]: { id: rep.id, type: "remove" } }),
(marks) => {
if (!rep.replace) return [];
return parseInlineMarkdown(rep.replace).map((seg: any) => {
if (seg.type === "a") {
return { ...seg, children: seg.children.map((c: any) => ({
...marks, ...c, suggestion: true, [sgKey]: { id: rep.id, type: "insert" },
}))};
}
return { ...marks, ...seg, suggestion: true, [sgKey]: { id: rep.id, type: "insert" } };
});
},
blockType
);
});
}
function splitNewlineBlocks(nodes: any[]): any[] {
return nodes.flatMap((node: any) => {
if (node.type !== "p") return [node];
const text = extractText(node);
if (!text.includes("\n")) return [node];
return text.split("\n").filter((l: string) => l.trim()).map((l: string) => ({
type: "p", children: [{ text: l.trim() }],
}));
});
}
function findBlockIndex(nodes: any[], text: string): number {
for (let i = 0; i < nodes.length; i++) {
if (blockToPlainText(nodes[i]).includes(text)) return i;
}
return -1;
}
function applyRetypeOp(
nodes: any[],
op: { find: string; blockType: string; id: string }
): any[] {
const idx = findBlockIndex(nodes, op.find);
if (idx === -1) return nodes;
const result = [...nodes];
const original = result[idx];
const fullText = blockToPlainText(original).trim();
const trimmedFind = op.find.trim();
// If find text is a substring of a larger block, split the block first
if (fullText !== trimmedFind && original.type === "p") {
const split = splitNewlineBlocks([original]);
if (split.length > 1) {
result.splice(idx, 1, ...split);
return applyRetypeOp(result, op);
}
}
const retyped = { ...structuredClone(original), type: op.blockType };
result.splice(idx, 1,
{ ...structuredClone(original), suggestion: { id: op.id, type: "remove" } },
{ ...retyped, suggestion: { id: op.id, type: "insert" } }
);
return result;
}
function applyInsertOp(
nodes: any[],
op: { after: string; markdown: string; id: string },
editor: any
): any[] {
const idx = findBlockIndex(nodes, op.after);
if (idx === -1) return nodes;
const newBlocks = markdownToNodes(editor, op.markdown);
const result = [...nodes];
const marked = newBlocks.map((b: any) => ({ ...b, suggestion: { id: op.id, type: "insert" } }));
result.splice(idx + 1, 0, ...marked);
return result;
}
function buildChangeCards(
nodes: any[],
rationaleMap: Map<string, string>
): ChangeCard[] {
const inserts = new Map<string, string>();
const removes = new Map<string, string>();
const blockHandled = new Set<string>();
function walk(node: any) {
if (node.children) {
if (typeof node.suggestion === "object" && node.suggestion !== null) {
const { id, type } = node.suggestion;
const text = extractText(node);
const map = type === "insert" ? inserts : removes;
map.set(id, (map.get(id) || "") + text);
blockHandled.add(id);
}
node.children.forEach(walk);
return;
}
for (const key of Object.keys(node)) {
if (key.startsWith("suggestion_") && node[key]?.id) {
const { id, type } = node[key];
if (blockHandled.has(id)) continue;
const map = type === "insert" ? inserts : removes;
map.set(id, (map.get(id) || "") + (node.text || ""));
}
}
}
nodes.forEach(walk);
const allIds = new Set([...inserts.keys(), ...removes.keys()]);
const cards: ChangeCard[] = [];
for (const id of allIds) {
cards.push({
id,
insertText: (inserts.get(id) || "").trim(),
removeText: (removes.get(id) || "").trim(),
rationale: rationaleMap.get(id),
});
}
return cards;
}
function truncate(text: string, max: number): string {
return text.length <= max ? text : text.slice(0, max) + "...";
}
// ============ Editor Helpers ============
function editorToMarkdown(editor: any): string {
function serializeInline(children: any[]): string {
return children?.map((c: any) => {
if (c.children) {
const inner = serializeInline(c.children);
if (c.type === "a" && c.url) return `[${inner}](${c.url})`;
return inner;
}
let t = c.text || "";
if (c.bold) t = `**${t}**`;
if (c.italic) t = `*${t}*`;
if (c.code) t = "`" + t + "`";
if (c.strikethrough) t = "~~" + t + "~~";
return t;
}).join("") || "";
}
function serializeBlock(node: any): string {
const text = serializeInline(node.children);
if (node.type === "h1") return `# ${text}`;
if (node.type === "h2") return `## ${text}`;
if (node.type === "h3") return `### ${text}`;
if (node.type === "blockquote") return `> ${text}`;
if (node.type === "hr") return "---";
if (node.type === "code_block") {
const lines = node.children?.map((line: any) => extractText(line)).join("\n") || "";
return "```\n" + lines + "\n```";
}
if (node.type === "ul") return node.children?.map((li: any) => `- ${serializeInline(li.children)}`).join("\n") || "";
if (node.type === "ol") return node.children?.map((li: any, i: number) => `${i + 1}. ${serializeInline(li.children)}`).join("\n") || "";
if (node.type === "table") {
const rows = node.children?.map((tr: any) =>
"| " + (tr.children?.map((td: any) => serializeInline(td.children)).join(" | ") || "") + " |"
) || [];
if (rows.length > 0) {
const numCols = node.children[0]?.children?.length || 1;
const delim = "| " + Array(numCols).fill("---").join(" | ") + " |";
rows.splice(1, 0, delim);
}
return rows.join("\n");
}
return text;
}
return editor.children.map(serializeBlock).join("\n\n");
}
function parseTableMarkdown(text: string): any[] {
const rows: string[][] = [];
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed || /^[\s|:\-]+$/.test(trimmed)) continue;
const cells = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c: string) => c.trim());
rows.push(cells);
}
if (rows.length === 0) return [];
return [{ type: "table", children: rows.map((cells, rowIdx) => ({
type: "tr", children: cells.map((c) => ({
type: rowIdx === 0 ? "th" : "td",
children: [{ type: "p", children: parseInlineMarkdown(c) }]
}))
}))}];
}
function markdownToNodes(editor: any, md: string): any[] {
// Extract tables before Plate's deserializer (which doesn't handle them).
// Replace with placeholders, let Plate handle inline formatting, then splice tables back.
const tables: string[] = [];
const processed = md.replace(/((?:^|\r?\n)\|.+\|\r?\n\|[-:\s|]+\|\r?\n(?:\|.+\|\r?\n?)*)/g, (match) => {
tables.push(match.trim());
return `\nTBPH_${tables.length - 1}\n`;
});
let nodes: any[];
try {
const deserialized = editor.api.markdown.deserialize(processed);
if (deserialized && deserialized.length > 0) {
nodes = deserialized;
} else {
throw new Error("Plate deserialize returned empty result");
}
} catch (e) {
console.warn("Plate markdown deserialize failed, using fallback:", e);
const blocks: any[] = [];
const lines = processed.split("\n");
let i = 0;
while (i < lines.length) {
const trimmed = lines[i].trim();
if (!trimmed) { i++; continue; }
if (trimmed.startsWith("```")) {
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].trim().startsWith("```")) { codeLines.push(lines[i]); i++; }
i++;
blocks.push({ type: "code_block", children: codeLines.map((l) => ({ type: "code_line", children: [{ text: l }] })) });
continue;
}
if (trimmed === "---" || trimmed === "***" || trimmed === "___") { blocks.push({ type: "hr", children: [{ text: "" }] }); i++; continue; }
if (/^[-*+] /.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^\s*[-*+] /.test(lines[i])) { items.push(lines[i].replace(/^\s*[-*+] /, "")); i++; }
blocks.push({ type: "ul", children: items.map((t) => ({ type: "li", children: [{ type: "p", children: [{ text: t }] }] })) });
continue;
}
if (/^\d+[.)] /.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^\s*\d+[.)] /.test(lines[i])) { items.push(lines[i].replace(/^\s*\d+[.)] /, "")); i++; }
blocks.push({ type: "ol", children: items.map((t) => ({ type: "li", children: [{ type: "p", children: [{ text: t }] }] })) });
continue;
}
if (trimmed.startsWith("### ")) { blocks.push({ type: "h3", children: [{ text: trimmed.slice(4) }] }); i++; continue; }
if (trimmed.startsWith("## ")) { blocks.push({ type: "h2", children: [{ text: trimmed.slice(3) }] }); i++; continue; }
if (trimmed.startsWith("# ")) { blocks.push({ type: "h1", children: [{ text: trimmed.slice(2) }] }); i++; continue; }
if (trimmed.startsWith("> ")) { blocks.push({ type: "blockquote", children: [{ text: trimmed.slice(2) }] }); i++; continue; }
blocks.push({ type: "p", children: [{ text: trimmed }] });
i++;
}
nodes = blocks.length > 0 ? blocks : [{ type: "p", children: [{ text: "" }] }];
}
// Splice table nodes back in place of placeholders
if (tables.length > 0) {
nodes = nodes.flatMap((node: any) => {
const text = extractText(node).trim();
const m = text.match(/^TBPH_(\d+)$/);
if (m) return parseTableMarkdown(tables[parseInt(m[1])]);
return [node];
});
}
return nodes;
}
const suggestionPlugin = toTPlatePlugin(BaseSuggestionPlugin, {
options: {
currentUserId: "ai",
},
});
const EDITOR_PLUGINS = [
ParagraphPlugin,
H1Plugin, H2Plugin, H3Plugin,
BoldPlugin, ItalicPlugin, CodePlugin, StrikethroughPlugin,
BlockquotePlugin,
BulletedListPlugin, NumberedListPlugin, ListItemPlugin,
CodeBlockPlugin, CodeLinePlugin,
LinkPlugin.withComponent(({ children, attributes, element }: any) => <a {...attributes} href={element.url} target="_blank" rel="noopener noreferrer" className="tb-link" title={`${element.url} (Ctrl+click to open)`} onClick={(e: any) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); window.open(element.url, "_blank"); }}}>{children}</a>),
TablePlugin.withComponent(({ children, attributes }: any) => <div {...attributes} className="tb-table">{children}</div>),
TableRowPlugin.withComponent(({ children, attributes }: any) => <div {...attributes} className="tb-tr">{children}</div>),
TableCellPlugin.withComponent(({ children, attributes }: any) => <div {...attributes} className="tb-td">{children}</div>),
TableCellHeaderPlugin.withComponent(({ children, attributes }: any) => <div {...attributes} className="tb-th">{children}</div>),
MarkdownPlugin,
suggestionPlugin,
];
function createEditorFromNodes(nodes: any[]) {
return createPlateEditor({
plugins: EDITOR_PLUGINS,
value: nodes,
});
}
function createEditorWithContent(markdown: string) {
// Create a temp editor to access markdown deserialization
const temp = createPlateEditor({
plugins: EDITOR_PLUGINS,
value: [{ type: "p", children: [{ text: "" }] }],
});
const nodes = markdownToNodes(temp, markdown);
return createEditorFromNodes(nodes);
}
// ============ App ============
function App() {
const [models, setModels] = useState<Model[]>([]);
const [model, setModel] = useState<Model | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [generatingLabel, setGeneratingLabel] = useState("");
const [customDirective, setCustomDirective] = useState("");
const [suggestionCount, setSuggestionCount] = useState(0);
const hasSuggestions = suggestionCount > 0;
const [changeCards, setChangeCards] = useState<ChangeCard[]>([]);
const [activeSgId, setActiveSgId] = useState<string | null>(null);
const [cardReplies, setCardReplies] = useState<Map<string, string>>(new Map());
const [reviewItems, setReviewItems] = useState<ReviewItem[]>([]);
const [reviewText, setReviewText] = useState("");
const [selectedReviewId, setSelectedReviewId] = useState<string | null>(null);
const [rationaleMap, setRationaleMap] = useState<Map<string, string>>(
new Map()
);
const [plateKey, setPlateKey] = useState(0);
const editorRef = useRef<any>(null);
const editorWrapperRef = useRef<HTMLDivElement>(null);
const rejectedRef = useRef<string[]>([]);
// Load models
useEffect(() => {
tb.models().then((available: Model[]) => {
setModels(available);
const savedModel = localStorage.getItem("writing-buddy-model");
const match = savedModel ? available.find((m) => `${m.provider}:${m.name}` === savedModel) : null;
setModel(match || available[0] || null);
});
}, []);
// Initialize editor (restore from localStorage or start blank)
useEffect(() => {
try {
const saved = localStorage.getItem("writing-buddy-essay");
if (saved) {
// Try JSON nodes first, fall back to markdown for backwards compat
try {
const nodes = JSON.parse(saved);
if (Array.isArray(nodes) && nodes.length > 0 && nodes[0].children) {
editorRef.current = createEditorFromNodes(nodes);
} else {
editorRef.current = createEditorWithContent(saved);
}
} catch (e) {
console.warn("JSON restore failed, falling back to markdown:", e);
editorRef.current = createEditorWithContent(saved);
}
} else {
editorRef.current = createEditorWithContent("");
}
} catch (e) {
console.error("Editor init failed:", e);
editorRef.current = createEditorWithContent("");
}
setPlateKey((k) => k + 1);
}, []);
// Auto-save to localStorage (periodic)
const suggestionCountRef = useRef(0);
suggestionCountRef.current = suggestionCount;
useEffect(() => {
const interval = window.setInterval(() => {
if (suggestionCountRef.current > 0) return;
const editor = editorRef.current;
if (!editor) return;
try {
const nodes = hasReviewMarksRef.current ? stripReviewMarks(editor.children) : editor.children;
localStorage.setItem("writing-buddy-essay", JSON.stringify(nodes));
} catch (e) { console.error("Auto-save failed:", e); }
}, 3000);
return () => clearInterval(interval);
}, []);
// Intercept plain-text paste at DOM level and deserialize as markdown.
// Uses capture phase so it fires before Slate/Plate's own handler.
useEffect(() => {
const el = editorWrapperRef.current;
if (!el) return;
const handler = (e: Event) => {
const ce = e as ClipboardEvent;
const html = ce.clipboardData?.getData('text/html');
const plain = ce.clipboardData?.getData('text/plain');
// Only defer to Plate's HtmlPlugin when the HTML has real rich formatting
// (links, bold, headings, lists, etc.) — not just an OS-generated wrapper
const hasRichHtml = html && /<(a|strong|em|b|i|h[1-6]|ul|ol|li|table|blockquote|pre|code)\b/i.test(html);
if (hasRichHtml) return;
if (!plain?.trim()) return;
e.preventDefault();
e.stopImmediatePropagation();
const editor = editorRef.current;
if (!editor) return;
editor.tf.insertFragment(markdownToNodes(editor, plain));
};
el.addEventListener('paste', handler, true);
return () => el.removeEventListener('paste', handler, true);
}, [plateKey]);
const callAI = async (system: string, userMessage: string) => {
if (!model) throw new Error("No model selected");
const response = await tb.ai({
provider: model.provider,
model: model.name,
system,
messages: [{ role: "user" as const, content: userMessage }],
});
return response.text;
};
// ===== Apply AI Revision =====
const applyAIRevision = async (
systemPrompt: string,
userMessage: string
) => {
const editor = editorRef.current;
if (!editor) throw new Error("No editor");
const rejections =
rejectedRef.current.length > 0
? `\n\nIMPORTANT: The author has already REJECTED these changes — do NOT repeat them:\n${rejectedRef.current.map((r) => `- ${r}`).join("\n")}`
: "";
const responseText = await callAI(
systemPrompt +
`\n\nYou MUST return edited JSON — never ask questions, never discuss options, never explain what you would do. If the directive is ambiguous, use your best judgment.
Return a JSON array of operations. Each operation has an "op" field and other fields depending on the type:
1. Text replacement (most common):
{ "op": "replace", "find": "exact substring to change", "replace": "new text (may use *italic* or **bold**)", "rationale": "why" }
2. Change block type (e.g. make a paragraph into a heading):
{ "op": "retype", "find": "text identifying the block", "blockType": "h1"|"h2"|"h3"|"p"|"blockquote", "rationale": "why" }
3. Insert new block after a target:
{ "op": "insert", "after": "text in the preceding block", "markdown": "content of the new block", "rationale": "why" }
Rules:
- Return ONLY operations that address the directive. Do NOT make other changes.
- Do NOT change punctuation style, quote style, or formatting unless the directive asks for it.
- For "replace" ops, "find" must be an exact substring of the essay text.
- For "retype" and "insert" ops, include enough text to uniquely identify the target block.
- Use "replace" for most edits. Use "retype" only when the block type itself needs changing. Use "insert" only when new content needs to be added.
- Return ONLY valid JSON: [...]` +
rejections,
userMessage
);
const parsed = sanitizeJson(responseText);
const ops: any[] = Array.isArray(parsed) ? parsed : [];
if (ops.length === 0) {
throw new Error("The AI didn't return usable operations. Try again.");
}
const plainText = editorToPlainText(editor);
let nodes = structuredClone(editor.children);
const rationale = new Map<string, string>();
// Collect text-level replacements
const replaceOps = ops.filter((o) => !o.op || o.op === "replace");
const located = replaceOps
.map((r: any) => {
const match = fuzzyFind(plainText, r.find || "");
return match ? { ...r, start: match.start, end: match.end } : null;
})
.filter((r: any): r is NonNullable<typeof r> => r !== null)
.filter((r: any) => normalizeText(r.find) !== normalizeText(r.replace));
if (located.length > 0) {
const withIds = located.map((r: any) => ({
...r,
id: genId(),
}));
nodes = applySuggestionReplacements(nodes, withIds);
for (const r of withIds) {
if (r.rationale) rationale.set(r.id, r.rationale);
}
}
// Apply block-level ops (back-to-front to preserve indices)
const retypeOps = ops.filter((o) => o.op === "retype");
const insertOps = ops.filter((o) => o.op === "insert");
// Retype ops (back-to-front by block index)
const retypeWithIdx = retypeOps
.map((o: any) => ({ ...o, idx: findBlockIndex(nodes, o.find || "") }))
.filter((o: any) => o.idx !== -1)
.sort((a: any, b: any) => b.idx - a.idx);
for (const op of retypeWithIdx) {
const id = genId();
nodes = applyRetypeOp(nodes, { find: op.find, blockType: op.blockType, id });
if (op.rationale) rationale.set(id, op.rationale);
}
// Insert ops (back-to-front by target index)
const insertWithIdx = insertOps
.map((o: any) => ({ ...o, idx: findBlockIndex(nodes, o.after || "") }))
.filter((o: any) => o.idx !== -1)
.sort((a: any, b: any) => b.idx - a.idx);
for (const op of insertWithIdx) {
const id = genId();
nodes = applyInsertOp(nodes, { after: op.after, markdown: op.markdown, id }, editor);
if (op.rationale) rationale.set(id, op.rationale);
}
// Propagate block-level suggestion marks to leaves for rendering
const suggested = propagateBlockSuggestions(nodes);
const cards = buildChangeCards(suggested, rationale);
replaceEditorNodes(suggested);
setRationaleMap(rationale);
setChangeCards(cards);
setSuggestionCount(collectSuggestionIds(suggested).size);
setActiveSgId(null);
};
// ===== Directives =====
const withGenerating = async (label: string, fn: () => Promise<void>) => {
setIsGenerating(true);
setGeneratingLabel(label);
try { await fn(); }
catch (err: any) { console.error(label, "error:", err); alert(err.message || `${label} failed`); }
finally { setIsGenerating(false); setGeneratingLabel(""); }
};
const runDirective = async (label: string, systemPrompt: string): Promise<boolean> => {
if (isGenerating || hasSuggestions) return false;
let ok = false;
await withGenerating(label, async () => {
await applyAIRevision(
`You are a skilled essay editor. ${systemPrompt}\n\nPreserve the author's voice and style. Only change what's necessary.`,
buildUserMessage("Apply the directive.")
);
ok = true;
});
return ok;
};
const handleCustomDirective = async () => {
if (!customDirective.trim()) return;
const editor = editorRef.current;
const isEmpty = !editor || editorToPlainText(editor).trim() === "";
if (isEmpty) {
// Empty doc: ask for markdown instead of operations
let ok = false;
await withGenerating("Drafting", async () => {
const md = await callAI(
`You are a skilled writer. The user wants you to write a document from scratch. Return ONLY the document content as markdown — no explanation, no wrapping, no code fences. Start directly with the content.`,
customDirective
);
replaceEditorNodes(markdownToNodes(
createPlateEditor({ plugins: EDITOR_PLUGINS, value: [{ type: "p", children: [{ text: "" }] }] }),
md.trim()
));
ok = true;
});
if (ok) setCustomDirective("");
} else {
if (await runDirective("Editing", `The author wants you to: ${customDirective}`)) {
setCustomDirective("");
}
}
};
// ===== Review (analysis-first) =====
const runReview = async () => {
if (isGenerating || hasSuggestions || reviewItems.length > 0) return;
const editor = editorRef.current;
if (!editor) return;
const plainText = editorToPlainText(editor);
await withGenerating("Reviewing", async () => {
const responseText = await callAI(
`You are a critical writing analyst. Determine what kind of writing this is and evaluate it on its own terms.
If you know something is wrong, or something good faith critics would push back on, say so. Avoid critiquing the author's style, but don't be shy critiquing their substance.
Lines beginning with > are blockquotes (cited material from other sources). NEVER flag issues with quoted text — only analyze the author's own words around and about those quotes.
For each issue, return:
- "quote": exact verbatim substring from the author's own prose (10-40 words, character-for-character)
- "issue": what's wrong and why (1-2 sentences)
The quote MUST be an exact substring of the essay text (NOT from blockquoted lines). If in doubt, use a shorter quote you are certain is verbatim.
If no issues are found, return {"issues": []}.
Return ONLY valid JSON: { "issues": [...] }`,
`<essay>\n${plainText}\n</essay>\n\nAnalyze the above essay. Return ONLY valid JSON.`
);
const items = parseReviewResponse(responseText);
if (items.length === 0) {
alert("No issues found — the essay looks sound.");
} else {
const ordered = items.map((c, i) => ({ id: `rv-${i}`, ...c, reply: "" }));
ordered.sort((a, b) => {
const posA = fuzzyFind(plainText, a.quote)?.start ?? Infinity;
const posB = fuzzyFind(plainText, b.quote)?.start ?? Infinity;
return posA - posB;
});
setReviewItems(ordered);
setReviewText(plainText);
}
});
};
const handleFixReview = async () => {
const items = [...reviewItems];
if (items.length === 0) return;
await withGenerating("Fixing", async () => {
const issueList = items
.map((c, i) => {
let item = `${i + 1}. "${c.quote}" — ${c.issue}`;
if (c.reply.trim()) item += `\n Author's note: ${c.reply.trim()}`;
return item;
})
.join("\n");
await applyAIRevision(
`You are a skilled essay editor. Fix the following issues identified in a review. For each issue, the author may have added a note with guidance — follow it.
Issues to fix:
${issueList}
Only fix the listed issues. Do NOT change anything else.`,
buildUserMessage("Fix the issues.")
);
setReviewItems([]);
setReviewText("");
setSelectedReviewId(null);
});
};
const hasReviewMarksRef = useRef(false);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
if (reviewItems.length === 0 && !hasReviewMarksRef.current) return;
const clean = stripReviewMarks(structuredClone(editor.children));
if (reviewItems.length > 0 && reviewText) {
const regions = buildReviewRegions(reviewText, reviewItems);
const marked = addReviewMarks(clean, regions);
replaceEditorNodes(marked);
hasReviewMarksRef.current = true;
} else {
replaceEditorNodes(clean);
hasReviewMarksRef.current = false;
}
}, [reviewItems, reviewText]);
useEffect(() => {
document.querySelectorAll(".rv-hl-selected").forEach((el) => el.classList.remove("rv-hl-selected"));
if (selectedReviewId) {
document.querySelectorAll(`[data-review-id="${selectedReviewId}"]`).forEach((el) => el.classList.add("rv-hl-selected"));
}
}, [selectedReviewId]);
// ===== Accept / Reject =====
const clearSuggestionState = () => {
setRationaleMap(new Map());
setChangeCards([]);
setActiveSgId(null);
setCardReplies(new Map());
setSuggestionCount(0);
};
const buildUserMessage = (instruction: string) => {
const editor = editorRef.current!;
return `<markdown-context>\n${editorToMarkdown(editor)}\n</markdown-context>\n\n<essay>\n${editorToPlainText(editor)}\n</essay>\n\nThe markdown-context shows formatting for reference only. The essay tag has the plain text — all "find" and "replace" strings must match/use PLAIN TEXT, never markdown syntax (no #, **, etc.). ${instruction} Return ONLY valid JSON.`;
};
const replaceEditorNodes = (nodes: any[]) => {
editorRef.current = createEditorFromNodes(nodes);
setPlateKey((k) => k + 1);
};
const resolveChange = (accept: boolean, id?: string) => {
const editor = editorRef.current;
if (!editor) return;
if (!accept) {
if (id) {
const r = rationaleMap.get(id);
if (r) rejectedRef.current.push(r);
} else {
for (const [, r] of rationaleMap) {
if (r) rejectedRef.current.push(r);
}
}
}
const resolved = resolveSuggestions(structuredClone(editor.children), accept, id);
replaceEditorNodes(resolved);
if (id) {
setChangeCards((cards) => cards.filter((c) => c.id !== id));
setActiveSgId(null);
} else {
clearSuggestionState();
}
setSuggestionCount(collectSuggestionIds(resolved).size);
};
const handleSmartAccept = async () => {
const editor = editorRef.current;
if (!editor) return;
const withReplies = changeCards.filter((c) => cardReplies.get(c.id)?.trim());
const withoutReplies = changeCards.filter((c) => !cardReplies.get(c.id)?.trim());
// No replies — accept all normally
if (withReplies.length === 0) {
resolveChange(true);
return;
}
// Save critique info before clearing state
const repliedCritiques = withReplies.map((c) => ({
rationale: rationaleMap.get(c.id) || "",
reply: cardReplies.get(c.id) || "",
}));
// Resolve in one pass: accept no-reply, revert replied (without tracking as rejected)
let nodes = structuredClone(editor.children);
for (const card of withoutReplies) {
nodes = resolveSuggestions(nodes, true, card.id);
}
for (const card of withReplies) {
nodes = resolveSuggestions(nodes, false, card.id);
}
replaceEditorNodes(nodes);
clearSuggestionState();
setSuggestionCount(collectSuggestionIds(nodes).size);
// Send replied critiques back to AI for re-fix
await withGenerating("Revising", async () => {
const critiqueList = repliedCritiques
.map((c, i) => {
let item = `${i + 1}. Critique: ${c.rationale}`;
item += `\n Author's note: ${c.reply}`;
return item;
})
.join("\n");
await applyAIRevision(
`You are a skilled essay editor. Address the following critiques, taking into account the author's notes on each.
${critiqueList}
Preserve the author's voice and style. Only change what's necessary.`,
buildUserMessage("Address the critiques.")
);
});
};
// ===== Suggestion Highlighting =====
const highlightSuggestion = (id: string) => {
document.querySelectorAll(".sg-active").forEach((el) => el.classList.remove("sg-active"));
document.querySelectorAll(`[data-suggestion-id="${id}"]`).forEach((el) => el.classList.add("sg-active"));
setActiveSgId(id);
};
const handleCardClick = (id: string) => {
highlightSuggestion(id);
document.querySelector(`[data-suggestion-id="${id}"]`)?.scrollIntoView({ behavior: "smooth", block: "center" });
};
// ===== Misc =====
// ===== Render =====
return (
<div className="workspace">
<div className="essay-pane">
<div ref={editorWrapperRef} className="editor-wrapper" onClick={(e) => {
const rvEl = (e.target as HTMLElement).closest("[data-review-id]") as HTMLElement;
if (rvEl) {
const id = rvEl.dataset.reviewId!;
setSelectedReviewId(id);
document.getElementById(`review-card-${id}`)?.scrollIntoView({ behavior: "smooth", block: "nearest" });
return;
}
const el = (e.target as HTMLElement).closest("[data-suggestion-id]") as HTMLElement;
if (el) {
highlightSuggestion(el.dataset.suggestionId!);
document.getElementById(`change-card-${el.dataset.suggestionId}`)?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}}>
{editorRef.current && (
<Plate key={plateKey} editor={editorRef.current}>
<PlateContent
className="plate-editor"
placeholder="Start writing or paste text here..."
readOnly={reviewItems.length > 0}
renderLeaf={EditorLeaf}
/>
</Plate>
)}
</div>
</div>
<div className="sidebar">
<h1 className="sidebar-title">Writing Buddy</h1>
<div className="sidebar-controls">
{models.length > 1 ? (
<select
className="model-select toolbar-model"
value={model?.name || ""}
onChange={(e) => {
const m = models.find((m) => m.name === e.target.value);
if (m) { setModel(m); localStorage.setItem("writing-buddy-model", `${m.provider}:${m.name}`); }
}}
>
{Object.entries(models.reduce((g: any, m: any) => { (g[m.provider] ??= []).push(m); return g; }, {})).map(([provider, ms]: any) => (
<optgroup key={provider} label={provider}>
{ms.map((m: any) => <option key={m.name} value={m.name}>{m.friendlyName}</option>)}
</optgroup>
))}
</select>
) : (
<span className="model-hint toolbar-model">Login to choose models</span>
)}
<button className="toolbar-btn" onClick={runReview} disabled={isGenerating} title="Critique your document.">Review</button>
<button className="toolbar-btn" title="Copy document as markdown" onClick={() => {
const md = editorToMarkdown(editorRef.current!);
navigator.clipboard.writeText(md).then(
() => alert("Copied to clipboard as markdown"),
() => alert("Failed to copy")
);
}}>Export</button>
</div>
{hasSuggestions ? (
<div className="sidebar-review">
<div className="review-header">
<h3>
{suggestionCount} Change
{suggestionCount !== 1 ? "s" : ""}
</h3>
<div className="review-bulk-actions">
<button
className="toolbar-btn accept-all-btn"
onClick={handleSmartAccept}
disabled={isGenerating}
>
Accept All
</button>
<button
className="toolbar-btn reject-all-btn"
onClick={() => resolveChange(false)}
>
Reject All
</button>
</div>
</div>
<div className="change-cards">
{changeCards.map((card) => (
<div
key={card.id}
id={`change-card-${card.id}`}
className={`change-card ${activeSgId === card.id ? "change-card-active" : ""}`}
onClick={() => handleCardClick(card.id)}
>
{card.removeText && (
<div className="change-old">
{truncate(card.removeText, 120)}
</div>
)}
{card.insertText && (
<div className="change-new">
{truncate(card.insertText, 120)}
</div>
)}
{card.rationale && (
<div className="change-rationale">{card.rationale}</div>
)}
<textarea
className="critique-reply"
placeholder="Reply (optional — guide the AI to fix differently)..."
value={cardReplies.get(card.id) || ""}
onChange={(e) => {
e.stopPropagation();
setCardReplies((m) => new Map(m).set(card.id, e.target.value));
}}
onClick={(e) => e.stopPropagation()}
rows={2}
/>
<div className="change-actions">
<button
className="change-accept"
onClick={(e) => {
e.stopPropagation();
resolveChange(true, card.id);
}}
>
Accept
</button>
<button
className="change-reject"
onClick={(e) => {
e.stopPropagation();
resolveChange(false, card.id);
}}
>
Reject
</button>
</div>
</div>
))}
</div>
</div>
) : reviewItems.length > 0 ? (
<div className="sidebar-review">
<div className="review-header">
<h3>
{reviewItems.length} Issue
{reviewItems.length !== 1 ? "s" : ""}
</h3>
<div className="review-bulk-actions">
<button
className="toolbar-btn accept-all-btn"
onClick={handleFixReview}
disabled={isGenerating}
>
Fix All
</button>
<button
className="toolbar-btn reject-all-btn"
onClick={() => { setReviewItems([]); setReviewText(""); setSelectedReviewId(null); }}
disabled={isGenerating}
>
Dismiss All
</button>
</div>
</div>
{isGenerating && (
<div className="generating-state">
<div className="generating-dot" />
{generatingLabel}
</div>
)}
<div className="change-cards">
{reviewItems.map((item) => (
<div
key={item.id}
id={`review-card-${item.id}`}
className={`change-card${selectedReviewId === item.id ? " change-card-active" : ""}`}
onClick={() => {
setSelectedReviewId(item.id);
document.querySelector(`[data-review-id="${item.id}"]`)?.scrollIntoView({ behavior: "smooth", block: "center" });
}}
>
{item.quote && (
<div className="review-quote">{truncate(item.quote, 120)}</div>
)}
<div className="review-issue">{item.issue}</div>
<textarea
className="critique-reply"
placeholder="Reply (optional — guide the fix)..."
value={item.reply}
onChange={(e) => {
e.stopPropagation();
setReviewItems((items) => items.map((c) =>
c.id === item.id ? { ...c, reply: e.target.value } : c
));
}}
onClick={(e) => e.stopPropagation()}
rows={2}
/>
<div className="change-actions">
<button
className="change-reject"
onClick={(e) => {
e.stopPropagation();
setReviewItems((items) => items.filter((c) => c.id !== item.id));
}}
>
Dismiss
</button>
</div>
</div>
))}
</div>
</div>
) : (
<>
<div className="sidebar-section">
<div className="custom-directive-wrap">
<textarea
className="custom-input"
placeholder="Tell the AI how to edit your document..."
value={customDirective}
onChange={(e) => setCustomDirective(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleCustomDirective();
}
}}
rows={4}
/>
<button
className="custom-send"
onClick={handleCustomDirective}
disabled={isGenerating || !customDirective.trim()}
title="Send (Enter)"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
</button>
</div>
</div>
{isGenerating && (
<div className="generating-state">
<div className="generating-dot" />
{generatingLabel}...
</div>
)}
</>
)}
</div>
</div>
);
}
createRoot(document.getElementById("root")!).render(<App />);