318 lines
12 KiB
TypeScript
318 lines
12 KiB
TypeScript
/**
|
||
* 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) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] as string),
|
||
);
|
||
}
|