diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index c8b2763..d8f9dc3 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -12,7 +12,8 @@ import { randomBytes } from "node:crypto"; import * as vscode from "vscode"; import type { DiffViewController } from "./diffViewController"; import type { AttributionController } from "./attributionController"; -import { renderTrackChanges, renderAuthorship, diffBlocks, type BlockOp } from "./trackChangesModel"; +import type { ProposalController } from "./proposalController"; +import { renderReview, renderPlain, diffBlocks, type BlockOp } from "./trackChangesModel"; const VIEW_TYPE = "cowriting.trackChangesPreview"; const DEBOUNCE_MS = 150; @@ -22,13 +23,16 @@ export class TrackChangesPreviewController implements vscode.Disposable { private readonly panels = new Map(); private readonly lastModel = new Map(); private readonly debounces = new Map(); - /** F9: per-panel view mode — track-changes (default) or authorship. */ - private readonly mode = new Map(); + /** F10: per-panel annotations mode — on (default) shows review marks, off is clean. */ + private readonly mode = new Map(); + /** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */ + private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88); constructor( private readonly diffView: DiffViewController, private readonly extensionUri: vscode.Uri, private readonly attribution: AttributionController, + private readonly proposals: ProposalController, ) { this.disposables.push( vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () => @@ -36,7 +40,13 @@ export class TrackChangesPreviewController implements vscode.Disposable { ), vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)), this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)), + this.proposals.onDidChangeProposals(({ uri }) => { + this.refreshByUri(uri); + this.updateStatus(uri); + }), + this.statusItem, ); + this.statusItem.command = "cowriting.showTrackChangesPreview"; } private isMarkdown(document: vscode.TextDocument): boolean { @@ -75,22 +85,33 @@ export class TrackChangesPreviewController implements vscode.Disposable { this.panels.delete(key); this.lastModel.delete(key); this.mode.delete(key); + // A panel is gone: re-show the off-panel indicator if proposals remain. + this.updateStatus(key); }, null, this.disposables, ); - // F9: the webview's header toggle posts the chosen mode back. + // F10: the webview posts the annotations toggle + ✓/✗ proposal decisions back. panel.webview.onDidReceiveMessage( - (m: { type?: string; mode?: "changes" | "authorship" }) => { - if (m?.type === "setMode" && (m.mode === "changes" || m.mode === "authorship")) { + (m: { type?: string; mode?: "on" | "off"; proposalId?: string }) => { + if (m?.type === "setMode" && (m.mode === "on" || m.mode === "off")) { this.mode.set(key, m.mode); this.refresh(document); + } else if (m?.type === "accept" && m.proposalId) { + void this.proposals + .acceptById(this.proposals.keyFor(document), m.proposalId) + .then(() => this.refresh(document)); + } else if (m?.type === "reject" && m.proposalId) { + this.proposals.rejectById(this.proposals.keyFor(document), m.proposalId); + this.refresh(document); } }, null, this.disposables, ); this.panels.set(key, panel); + // A panel is now open for this doc — the off-panel indicator is redundant. + this.statusItem.hide(); this.refresh(document); } @@ -118,41 +139,49 @@ export class TrackChangesPreviewController implements vscode.Disposable { const key = document.uri.toString(); const panel = this.panels.get(key); if (!panel) return; - const mode = this.mode.get(key) ?? "changes"; + const mode = this.mode.get(key) ?? "on"; const current = document.getText(); - if (mode === "authorship") { - // F9: render the current buffer colored by F3 author, baseline-independent. - const spans = this.attribution.spansFor(document); - this.lastModel.set(key, diffBlocks(this.diffView.getBaseline(key)?.text ?? current, current)); - void panel.webview.postMessage({ - type: "render", - mode, - html: renderAuthorship(current, spans), - legend: { - claude: spans.some((s) => s.author === "claude"), - human: spans.some((s) => s.author === "human"), - }, - }); - return; - } const baseline = this.diffView.getBaseline(key); - const baselineText = baseline?.text ?? current; // no baseline → no marks + const baselineText = baseline?.text ?? current; // no baseline → no change-marks const ops = diffBlocks(baselineText, current); this.lastModel.set(key, ops); + if (mode === "off") { + void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current) }); + return; + } + const spans = this.attribution.spansFor(document); + const proposals = this.proposals.listProposals(document); const summary = { added: ops.filter((o) => o.kind === "added").length, removed: ops.filter((o) => o.kind === "removed").length, - changed: ops.filter((o) => o.kind === "changed").length, + proposals: proposals.length, }; void panel.webview.postMessage({ type: "render", mode, - html: renderTrackChanges(baselineText, current), + html: renderReview(baselineText, current, spans, proposals), epoch: this.epochLabel(baseline), summary, }); } + /** F10 (PUC-6): off-panel proposal indicator on the active doc. Hidden when a panel is open. */ + private updateStatus(uri: string): void { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); + if (!doc) { + this.statusItem.hide(); + return; + } + const n = this.proposals.listProposals(doc).length; + if (n === 0 || this.panels.has(uri)) { + this.statusItem.hide(); + return; + } + this.statusItem.text = `$(comment-discussion) ${n} Claude proposal${n === 1 ? "" : "s"}`; + this.statusItem.tooltip = "Cowriting: open the review preview to accept/reject Claude's proposals"; + this.statusItem.show(); + } + private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string { if (!baseline) return "opened (no baseline yet)"; const time = new Date(baseline.capturedAt).toLocaleTimeString(); @@ -193,13 +222,10 @@ export class TrackChangesPreviewController implements vscode.Disposable {
-
- - -
- Track changes + + Review - +
@@ -214,24 +240,33 @@ export class TrackChangesPreviewController implements vscode.Disposable { getLastModel(uriString: string): BlockOp[] | undefined { return this.lastModel.get(uriString); } - /** F7.1 (#22) test seam: the track-changes HTML the panel would post for a doc. */ + /** F10 test seam: the review HTML the panel would post for a doc (on-state). */ renderHtmlFor(uriString: string): string { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); if (!doc) return ""; const current = doc.getText(); const baseline = this.diffView.getBaseline(uriString); - return renderTrackChanges(baseline?.text ?? current, current); + return renderReview( + baseline?.text ?? current, + current, + this.attribution.spansFor(doc), + this.proposals.listProposals(doc), + ); } - /** F9: current view mode for a panel (default track-changes). */ - getMode(uriString: string): "changes" | "authorship" { - return this.mode.get(uriString) ?? "changes"; + /** F10: current annotations mode for a panel (default on). */ + getMode(uriString: string): "on" | "off" { + return this.mode.get(uriString) ?? "on"; } - /** F9: set the view mode and re-render (the programmatic twin of the header toggle). */ - setMode(uriString: string, mode: "changes" | "authorship"): void { + /** F10: set the annotations mode and re-render (the programmatic twin of the header toggle). */ + setMode(uriString: string, mode: "on" | "off"): void { this.mode.set(uriString, mode); const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); if (doc) this.refresh(doc); } + /** F10 test seam (SLICE-4 E2E): the off-panel status-bar indicator text, if shown. */ + statusText(): string | undefined { + return this.statusItem.text || undefined; + } dispose(): void { for (const t of this.debounces.values()) clearTimeout(t);