markdown-review/server/src/app/mcp-app.ts

318 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* markdown-review webview app.
*
* Renders a markdown file and lets the user comment on selected spans. Three lanes:
* - Answer Now → app.sendMessage(one comment) immediately (ephemeral, Lane B)
* - Add to batch → held locally (Lane A)
* - Submit All → callServerTool('submit_batch') to persist, then sendMessage the bundle
*
* SPIKE anchoring: offsets are derived by locating the selected text in the raw
* markdown (indexOf). Robust re-anchoring across edits is Phase 5 (DESIGN.md §2).
*/
import { App } from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import MarkdownIt from "markdown-it";
interface Anchor {
line_start: number;
line_end: number;
char_start: number;
char_end: number;
quote: string;
}
interface Comment {
id: string;
anchor: Anchor;
body: string;
store: "memory" | "sidecar" | "inline";
created: string;
}
const md = new MarkdownIt({ html: false, linkify: true, breaks: false });
const state = {
path: "" as string,
markdown: "" as string,
mode: "edit" as "edit" | "review",
batch: [] as Comment[],
pending: null as Anchor | null,
};
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
const docEl = $("doc");
const railEl = $("rail");
const pathEl = $("path");
const popEl = $("pop");
const popQuote = $("popQuote");
const popText = $<HTMLTextAreaElement>("popText");
const submitAllBtn = $<HTMLButtonElement>("submitAll");
const app = new App({ name: "Markdown Review", version: "0.0.1" });
// ── render ────────────────────────────────────────────────────────────────────
/** Hide YAML frontmatter from the rendered view (it's metadata, not body). */
function stripFrontmatter(src: string): string {
return src.replace(/^?---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
}
function renderDoc(): void {
// Render without frontmatter; anchoring still uses the full state.markdown,
// so comment offsets stay correct against the real file.
docEl.innerHTML = md.render(stripFrontmatter(state.markdown));
pathEl.textContent = state.path || "no file";
}
function renderRail(): void {
if (state.batch.length === 0) {
railEl.innerHTML = `<div class="empty">No comments yet.</div>`;
} else {
railEl.innerHTML = state.batch
.map(
(c) =>
`<div class="ccard"><div class="q">${escapeHtml(
truncate(c.anchor.quote, 90),
)}</div><div class="b">${escapeHtml(c.body)}</div></div>`,
)
.join("");
}
submitAllBtn.textContent = `Submit All (${state.batch.length})`;
submitAllBtn.disabled = state.batch.length === 0;
}
// ── selection → anchor ──────────────────────────────────────────────────────────
function anchorFromSelection(text: string): Anchor {
const idx = state.markdown.indexOf(text);
const char_start = idx >= 0 ? idx : 0;
const char_end = idx >= 0 ? idx + text.length : text.length;
const before = state.markdown.slice(0, char_start);
const line_start = before.split("\n").length;
const line_end = line_start + text.split("\n").length - 1;
return { line_start, line_end, char_start, char_end, quote: text };
}
document.addEventListener("mouseup", () => {
const sel = window.getSelection();
const text = sel?.toString().trim() ?? "";
if (!text || !sel || sel.rangeCount === 0 || !docEl.contains(sel.anchorNode)) {
return;
}
state.pending = anchorFromSelection(text);
const rect = sel.getRangeAt(0).getBoundingClientRect();
popQuote.textContent = truncate(text, 160);
popText.value = "";
popEl.style.display = "block";
const top = Math.min(rect.bottom + window.scrollY + 6, window.innerHeight - 180);
popEl.style.top = `${top}px`;
popEl.style.left = `${Math.min(rect.left + window.scrollX, window.innerWidth - 320)}px`;
popText.focus();
});
document.addEventListener("mousedown", (e) => {
if (!popEl.contains(e.target as Node)) popEl.style.display = "none";
});
// ── lane B: Answer Now ──────────────────────────────────────────────────────────
$("answerNow").addEventListener("click", async () => {
const body = popText.value.trim();
if (!state.pending || !body) return;
popEl.style.display = "none";
await sendToAgent([{ ...mkComment(state.pending, body, "memory") }], /*single*/ true);
state.pending = null;
});
// ── lane A: add to batch ────────────────────────────────────────────────────────
$("addBatch").addEventListener("click", () => {
const body = popText.value.trim();
if (!state.pending || !body) return;
state.batch.push(mkComment(state.pending, body, "sidecar"));
state.pending = null;
popEl.style.display = "none";
renderRail();
});
// ── lane A flush: Submit All ────────────────────────────────────────────────────
submitAllBtn.addEventListener("click", async () => {
if (state.batch.length === 0) return;
try {
await app.callServerTool({
name: "submit_batch",
arguments: { file: state.path, comments: state.batch, mode: state.mode },
});
} catch (e) {
console.error("submit_batch persist failed:", e);
}
await sendToAgent(state.batch, /*single*/ false);
state.batch = [];
renderRail();
});
// ── mode toggle ─────────────────────────────────────────────────────────────────
$("mode").addEventListener("click", (e) => {
const t = e.target as HTMLElement;
const m = t.dataset.mode as "edit" | "review" | undefined;
if (!m) return;
state.mode = m;
$("mode")
.querySelectorAll("button")
.forEach((b) => b.classList.toggle("on", (b as HTMLElement).dataset.mode === m));
});
// ── send to the conversation ────────────────────────────────────────────────────
async function sendToAgent(comments: Comment[], single: boolean): Promise<void> {
const verb =
state.mode === "edit"
? "Apply these review comments by editing the file"
: "Add these review comments to a punch-list (do not edit the file yet)";
const header = single
? `Quick question on \`${state.path}\`:`
: `${verb} \`${state.path}\` (${comments.length} comment(s)):`;
const blocks = comments
.map(
(c) =>
`--- L${c.anchor.line_start}-${c.anchor.line_end}\n> ${c.anchor.quote.replace(
/\n/g,
"\n> ",
)}\nComment: ${c.body}`,
)
.join("\n\n");
const text = `${header}\n\n${blocks}`;
try {
const { isError } = await app.sendMessage({
role: "user",
content: [{ type: "text", text }],
});
if (isError) console.warn("Host rejected sendMessage");
} catch (e) {
console.error("sendMessage failed:", e);
}
}
// ── host wiring ─────────────────────────────────────────────────────────────────
type IngestShape = { path?: string; mode?: string; markdown?: string; existingComments?: Comment[] };
/** Pull the {path,mode,markdown,…} payload out of whatever shape the host delivers. */
function coerceData(raw: unknown): IngestShape | undefined {
if (!raw || typeof raw !== "object") return undefined;
const r = raw as Record<string, unknown>;
// 1) standard MCP: result.structuredContent
const sc = r.structuredContent as IngestShape | undefined;
if (sc && typeof sc.markdown === "string") return sc;
// 2) some hosts hand the structured object straight through
if (typeof r.markdown === "string") return r as IngestShape;
// 3) last resort: a JSON payload embedded in a text content block
const content = r.content as Array<{ type?: string; text?: string }> | undefined;
if (Array.isArray(content)) {
for (const blk of content) {
if (blk?.type === "text" && typeof blk.text === "string") {
try {
const parsed = JSON.parse(blk.text) as IngestShape;
if (typeof parsed.markdown === "string") return parsed;
} catch {
/* not JSON — ignore */
}
}
}
}
return undefined;
}
// Visible lifecycle log — failures are never a silent black box again.
const diagLines: string[] = [];
function diag(msg: string): void {
console.info("[md-review]", msg);
diagLines.push(msg);
if (!state.markdown) {
docEl.innerHTML =
`<div class="empty"><strong>Markdown Review — loading…</strong>` +
`<div style="margin-top:8px;font-family:monospace;font-size:11px;white-space:pre-wrap;opacity:.8">` +
diagLines.map((l) => "• " + escapeHtml(l)).join("\n") +
`</div></div>`;
}
}
function ingestResult(result: unknown): boolean {
const sc = coerceData(result);
if (!sc?.markdown) return false;
state.path = sc.path ?? state.path ?? "";
state.markdown = sc.markdown;
state.mode = (sc.mode as "edit" | "review") ?? state.mode ?? "edit";
state.batch = Array.isArray(sc.existingComments) ? (sc.existingComments as Comment[]) : state.batch;
renderDoc();
renderRail();
return true;
}
let loaded = false;
let connected = false;
async function fetchContent(): Promise<void> {
if (loaded || !state.path) return;
diag("fetching content for " + state.path);
try {
const r = await app.callServerTool({
name: "read_markdown",
arguments: { path: state.path, mode: state.mode },
});
if (ingestResult(r)) {
loaded = true;
diag("loaded ✓");
} else {
diag("read_markdown returned no markdown: " + JSON.stringify(r).slice(0, 200));
}
} catch (e) {
diag("read_markdown call failed: " + String(e));
}
}
// The path arrives via the tool INPUT; we then fetch the file ourselves.
app.ontoolinput = (params: unknown) => {
const p = params as { arguments?: Record<string, unknown> } | undefined;
const args = (p?.arguments ?? (params as Record<string, unknown>) ?? {}) as Record<string, unknown>;
diag("ontoolinput: " + JSON.stringify(args).slice(0, 160));
if (typeof args.path === "string") {
state.path = args.path;
pathEl.textContent = args.path;
}
if (args.mode === "edit" || args.mode === "review") state.mode = args.mode;
if (connected) void fetchContent();
};
// If the host DOES deliver the result, use it directly (no fetch needed).
app.ontoolresult = (result: CallToolResult) => {
diag("ontoolresult received");
if (ingestResult(result)) loaded = true;
};
app.onerror = (e: unknown) => diag("app error: " + String(e));
diag("widget loaded; connecting…");
app
.connect()
.then(() => {
connected = true;
diag("connected");
if (state.path) void fetchContent();
setTimeout(() => {
if (!loaded) {
diag(
state.path
? "no document yet (path known — fetch pending/failed above)"
: "no path delivered by host (neither ontoolinput nor ontoolresult fired with one)",
);
}
}, 2500);
})
.catch((e) => diag("connect FAILED: " + String(e)));
// ── utils ───────────────────────────────────────────────────────────────────────
let seq = 0;
function mkComment(anchor: Anchor, body: string, store: Comment["store"]): Comment {
return { id: `c${Date.now().toString(36)}-${seq++}`, anchor, body, store, created: new Date().toISOString() };
}
function truncate(s: string, n: number): string {
return s.length > n ? s.slice(0, n - 1) + "…" : s;
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c] as string),
);
}