diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index f3634db..433a1d9 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -111,7 +111,7 @@ export class TrackChangesPreviewController implements vscode.Disposable { ); this.panels.set(key, panel); // A panel is now open for this doc — the off-panel indicator is redundant. - this.statusItem.hide(); + this.hideStatus(); this.refresh(document); } @@ -169,12 +169,12 @@ export class TrackChangesPreviewController implements vscode.Disposable { private updateStatus(uri: string): void { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); if (!doc) { - this.statusItem.hide(); + this.hideStatus(); return; } const n = this.proposals.listProposals(doc).length; if (n === 0 || this.panels.has(uri)) { - this.statusItem.hide(); + this.hideStatus(); return; } this.statusItem.text = `$(comment-discussion) ${n} Claude proposal${n === 1 ? "" : "s"}`; @@ -182,6 +182,15 @@ export class TrackChangesPreviewController implements vscode.Disposable { this.statusItem.show(); } + /** + * Hide the off-panel indicator AND clear its text, so the `statusText()` seam + * is honest: a hidden indicator reports `undefined` (not its stale last value). + */ + private hideStatus(): void { + this.statusItem.text = ""; + this.statusItem.hide(); + } + private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string { if (!baseline) return "opened (no baseline yet)"; const time = new Date(baseline.capturedAt).toLocaleTimeString(); diff --git a/test/e2e/suite-no-workspace/noWorkspace.test.ts b/test/e2e/suite-no-workspace/noWorkspace.test.ts index b36ddc0..0201df9 100644 --- a/test/e2e/suite-no-workspace/noWorkspace.test.ts +++ b/test/e2e/suite-no-workspace/noWorkspace.test.ts @@ -29,14 +29,20 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => { "cowriting.resolveThread", "cowriting.reopenThread", "cowriting.editSelection", - "cowriting.toggleAttribution", "cowriting.applyAgentEdit", - "cowriting.acceptProposal", - "cowriting.rejectProposal", "cowriting.proposeAgentEdit", ]) { assert.ok(all.includes(command), `${command} is registered`); } + // F10 (INV-32): proposals are preview-only — the in-editor accept/reject + // commands and the attribution toggle were retired (no editor decorations). + for (const retired of [ + "cowriting.toggleAttribution", + "cowriting.acceptProposal", + "cowriting.rejectProposal", + ]) { + assert.ok(!all.includes(retired), `${retired} is retired (F10 preview-only)`); + } }); test("authoring works folder-less: propose→accept on an untitled buffer routes to global storage (F8)", async () => { diff --git a/test/e2e/suite/attribution.test.ts b/test/e2e/suite/attribution.test.ts index 5bc615d..a1b1010 100644 --- a/test/e2e/suite/attribution.test.ts +++ b/test/e2e/suite/attribution.test.ts @@ -132,7 +132,7 @@ suite("F3 live attribution (host E2E — seam-driven, no LLM)", () => { assert.ok(api.attributionController.getOrphanCount(DOC_REL) >= 1, "…it is orphaned instead (INV-1)"); }); - test("the applyAgentEdit command wrapper and the toggle command work end-to-end", async () => { + test("the applyAgentEdit command wrapper works end-to-end (data layer; no editor decorations — F10/INV-32)", async () => { const doc = await openDoc(); const api = await getApi(); const anchor = "stable first paragraph"; @@ -153,10 +153,9 @@ suite("F3 live attribution (host E2E — seam-driven, no LLM)", () => { assert.ok(agent, "command-driven agent span exists"); assert.strictEqual(agent!.authorKind, "agent"); - assert.strictEqual(api.attributionController.isVisible(), true); - await vscode.commands.executeCommand("cowriting.toggleAttribution"); - assert.strictEqual(api.attributionController.isVisible(), false, "toggle hides (PUC-5)"); - await vscode.commands.executeCommand("cowriting.toggleAttribution"); - assert.strictEqual(api.attributionController.isVisible(), true, "toggle restores"); + // F10/INV-32: the editor carries no attribution decorations and the toggle was + // retired — the rendered preview is the single review surface. + const all = await vscode.commands.getCommands(true); + assert.ok(!all.includes("cowriting.toggleAttribution"), "the in-editor attribution toggle is retired (F10)"); }); }); diff --git a/test/e2e/suite/authorship.test.ts b/test/e2e/suite/authorship.test.ts index b37f4d7..638c161 100644 --- a/test/e2e/suite/authorship.test.ts +++ b/test/e2e/suite/authorship.test.ts @@ -14,16 +14,20 @@ async function getApi(): Promise { return api; } -// F9 host E2E (no LLM): authorship mode reflects Claude's landed span. Owns its -// own markdown doc, disjoint from the other suites' fixtures. -suite("F9 authorship preview (host E2E — seam ingress, no LLM)", () => { - const DOC_REL = "docs/f9authorship.md"; +// F10 host E2E (no LLM): the rewrite of the obsolete F9 authorship-mode test. +// F9's "authorship" mode / renderAuthorship is gone — the on-state renderReview +// now author-colors Claude's landed prose. This suite confirms a Claude-landed +// span renders as a cw-by-claude span in the on-state preview HTML. Owns its own +// markdown doc, disjoint from the other suites' fixtures. +suite("F10 review preview — Claude-authored prose is cw-by-claude in the on-state (host E2E, no LLM)", () => { + const DOC_REL = "docs/f10claude.md"; const TARGET = "The sentence Claude will compose over."; + const REPLACEMENT = "The sentence CLAUDE COMPOSED via the seam."; - test("authorship mode marks Claude's accepted edit; track-changes mode still works", async () => { + test("an accepted Claude edit author-colors as cw-by-claude in the on-state render; mode defaults to on", async () => { const abs = path.join(WS, DOC_REL); fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, `# F9\n\n${TARGET}\n`, "utf8"); + fs.writeFileSync(abs, `# F10\n\n${TARGET}\n`, "utf8"); const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); @@ -31,11 +35,11 @@ suite("F9 authorship preview (host E2E — seam ingress, no LLM)", () => { const api = await getApi(); const key = uri.toString(); - // open the preview (track-changes mode by default) + // open the preview — annotations default ON (F10/INV-33) await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); await settle(); assert.ok(api.trackChangesPreviewController.isOpen(key), "preview open"); - assert.strictEqual(api.trackChangesPreviewController.getMode(key), "changes", "defaults to track-changes"); + assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default to on"); // Claude composes via the seam (propose → accept) const start = doc.getText().indexOf(TARGET); @@ -43,32 +47,22 @@ suite("F9 authorship preview (host E2E — seam ingress, no LLM)", () => { uri: key, start, end: start + TARGET.length, - newText: "The sentence CLAUDE COMPOSED.", + newText: REPLACEMENT, model: "sonnet", - sessionId: "e2e-f9", - turnId: "turn-f9", + sessionId: "e2e-f10", + turnId: "turn-f10", }); - assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies"); + assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam"); await settle(); - // attribution has a Claude span now + // attribution recorded a Claude (agent) span (data layer intact) const claudeSpan = api.attributionController.getSpans(DOC_REL).find((s) => s.authorKind === "agent"); assert.ok(claudeSpan, "Claude span recorded by F3"); - - // flip to authorship mode → the preview reads a Claude span - api.trackChangesPreviewController.setMode(key, "authorship"); - await settle(); - assert.strictEqual(api.trackChangesPreviewController.getMode(key), "authorship"); const spans = api.attributionController.spansFor(doc); assert.ok(spans.some((s) => s.author === "claude"), "spansFor reports a Claude span for the preview"); - // back to track-changes — still functional (regression) - api.trackChangesPreviewController.setMode(key, "changes"); - await settle(); - assert.strictEqual(api.trackChangesPreviewController.getMode(key), "changes"); - assert.ok( - (api.trackChangesPreviewController.getLastModel(key) ?? []).length >= 1, - "track-changes model still computed", - ); + // the on-state render author-colors the landed Claude text as cw-by-claude + const html = api.trackChangesPreviewController.renderHtmlFor(key); + assert.match(html, //, "landed Claude prose is author-colored in the on-state render"); }); }); diff --git a/test/e2e/suite/f10Review.test.ts b/test/e2e/suite/f10Review.test.ts new file mode 100644 index 0000000..94b719a --- /dev/null +++ b/test/e2e/suite/f10Review.test.ts @@ -0,0 +1,238 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import type { CowritingApi } from "../../../src/extension"; +import { renderPlain } from "../../../src/trackChangesModel"; + +const WS = process.env.E2E_WORKSPACE!; +const settle = () => new Promise((r) => setTimeout(r, 400)); + +async function getApi(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.trackChangesPreviewController && api?.proposalController, "exports preview + proposal"); + return api; +} + +/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */ +async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { + const abs = path.join(WS, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body, "utf8"); + const uri = vscode.Uri.file(abs); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await settle(); + return { doc, key: uri.toString() }; +} + +async function propose( + doc: vscode.TextDocument, + key: string, + target: string, + newText: string, + turnId: string, +): Promise { + const start = doc.getText().indexOf(target); + assert.ok(start >= 0, `fixture contains "${target}"`); + const id = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { + uri: key, + start, + end: start + target.length, + newText, + model: "sonnet", + sessionId: "e2e-f10rev", + turnId, + }); + assert.ok(id, "propose returns an id"); + return id!; +} + +// F10 host E2E (no LLM): the rendered preview is the single INTERACTIVE review +// surface. This suite owns docs/f10review.md (its main flow is order-dependent) +// plus its own disjoint fresh docs for the isolated cases (status-bar, toggle). +// The editor is zero-decoration; everything observable here is the data layer + +// the on-state renderReview HTML the panel posts. +suite("F10 interactive review (host E2E — preview is the single review surface, no LLM)", () => { + const DOC_REL = "docs/f10review.md"; + const PROSE = "The original review paragraph that lives in this doc."; + const T1 = "A first claude target sentence here."; + const T2 = "A second claude target sentence here."; + + test("open on a markdown doc → panel open, fresh baseline all-unchanged, mode is on (PUC-1)", async () => { + const { doc, key } = await freshDoc(DOC_REL, `# F10 review\n\n${PROSE}\n\n${T1}\n\n${T2}\n`); + const api = await getApi(); + assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel yet"); + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open"); + assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "annotations default on (INV-33)"); + const model = api.trackChangesPreviewController.getLastModel(key); + assert.ok(model && model.length > 0, "a model was computed"); + assert.ok(model!.every((o) => o.kind === "unchanged"), "fresh baseline == buffer → every block unchanged"); + void doc; + }); + + test("typing produces an added/changed block and a cw-by-human span in the on-state render (PUC-2)", async () => { + const { key } = await reopen(DOC_REL); + const api = await getApi(); + const doc = byKey(key)!; + const edit = new vscode.WorkspaceEdit(); + edit.insert(doc.uri, doc.positionAt(doc.getText().length), "\n\nA freshly typed human paragraph.\n"); + assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied"); + await settle(); + const kinds = (api.trackChangesPreviewController.getLastModel(key) ?? []).map((o) => o.kind); + assert.ok(kinds.some((k) => k === "added" || k === "changed"), "an added/changed block after typing"); + // Attribution recorded the human span (data layer), and the on-state render + // author-colors that prose as cw-by-human. + assert.ok( + api.attributionController.spansFor(doc).some((s) => s.author === "human"), + "a human span was recorded for the typed text", + ); + const html = api.trackChangesPreviewController.renderHtmlFor(key); + assert.match(html, //, "typed text is author-colored human in the on-state"); + }); + + test("propose → the preview surfaces it as a cw-proposal block with ✓/✗ actions (PUC-3)", async () => { + const { doc, key } = await reopen(DOC_REL); + const api = await getApi(); + const id = await propose(doc, key, T1, "A FIRST claude REPLACEMENT sentence.", "turn-f10-1"); + await settle(); + const views = api.proposalController.listProposals(doc); + assert.ok(views.some((v) => v.id === id), "listProposals returns a view with the id"); + const html = api.trackChangesPreviewController.renderHtmlFor(key); + assert.ok(html.includes(`data-proposal-id="${id}"`), "the preview renders the proposal block by id"); + assert.match(html, /class="cw-actions"/, "the proposal block carries ✓/✗ actions"); + // INV-10: proposing never touches the document. + assert.ok(doc.getText().includes(T1), "document unchanged by propose"); + }); + + test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => { + const { doc, key } = await reopen(DOC_REL); + const api = await getApi(); + const id = api.proposalController.listProposals(doc).find((v) => v.replaced === T1)!.id; + assert.ok(await api.proposalController.acceptById(DOC_REL, id), "accept applies via the seam"); + await settle(); + const replacement = "A FIRST claude REPLACEMENT sentence."; + assert.ok(doc.getText().includes(replacement), "replacement landed in the document"); + assert.ok(!doc.getText().includes(T1), "original target gone"); + assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id), "proposal cleared from listProposals"); + const html = api.trackChangesPreviewController.renderHtmlFor(key); + assert.ok(!html.includes(`data-proposal-id="${id}"`), "the accepted proposal block is gone from the preview"); + // The baseline advanced on the landing (INV-18): the landed text is not marked. + const model = api.trackChangesPreviewController.getLastModel(key) ?? []; + const marked = model.some((o) => o.kind !== "unchanged" && o.block.raw.includes("FIRST claude REPLACEMENT")); + assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)"); + }); + + test("reject → the proposal vanishes and the document is untouched (PUC-5)", async () => { + const { doc, key } = await reopen(DOC_REL); + const api = await getApi(); + const before = doc.getText(); + const id2 = await propose(doc, key, T2, "A SECOND would-be replacement.", "turn-f10-2"); + await settle(); + assert.ok(api.proposalController.listProposals(doc).some((v) => v.id === id2), "second proposal pending"); + assert.strictEqual(api.proposalController.rejectById(DOC_REL, id2), true, "reject"); + await settle(); + assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id2), "rejected proposal gone"); + assert.strictEqual(doc.getText(), before, "document untouched by reject"); + const html = api.trackChangesPreviewController.renderHtmlFor(key); + assert.ok(!html.includes(`data-proposal-id="${id2}"`), "no rejected block in the preview"); + }); + + test("toggle annotations off → mode round-trips and the off-state render is plain (no cw- marks) (INV-33)", async () => { + // A fresh doc with a pending proposal so the on-state DOES carry a cw- mark, + // making the off-state's absence of marks meaningful (not tautological). + const { doc, key } = await freshDoc( + "docs/f10toggle.md", + "# F10 toggle\n\nA toggle target sentence to propose over.\n", + ); + const api = await getApi(); + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + const id = await propose(doc, key, "A toggle target sentence to propose over.", "A TOGGLED replacement.", "turn-tog"); + await settle(); + + // on-state: the proposal block + its actions are present. + assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "starts on"); + const onHtml = api.trackChangesPreviewController.renderHtmlFor(key); + assert.ok(onHtml.includes(`data-proposal-id="${id}"`), "on-state shows the proposal block"); + + // toggle off → mode round-trips; the off-state body is plain markdown. + api.trackChangesPreviewController.setMode(key, "off"); + await settle(); + assert.strictEqual(api.trackChangesPreviewController.getMode(key), "off", "mode flipped to off"); + assert.ok(api.trackChangesPreviewController.isOpen(key), "panel stays open across the toggle"); + // renderHtmlFor is the on-state seam; the off-state body is renderPlain(current) + // (INV-33). Assert the actual off-state body the controller posts has no cw- + // author/proposal marks — meaningful because the on-state above DID carry one. + const offBody = renderPlain(doc.getText()); + assert.ok(!/cw-proposal|cw-by-claude|cw-by-human|cw-del/.test(offBody), "off-state render carries no cw- marks"); + + // toggle back on → marks return. + api.trackChangesPreviewController.setMode(key, "on"); + await settle(); + assert.strictEqual(api.trackChangesPreviewController.getMode(key), "on", "mode flipped back on"); + assert.ok( + api.trackChangesPreviewController.renderHtmlFor(key).includes(`data-proposal-id="${id}"`), + "on-state shows the proposal block again", + ); + }); + + test("status-bar (PUC-6): a pending proposal with NO panel shows the indicator; opening the preview hides it", async () => { + // Isolated fresh doc: no preview opened, so the off-panel indicator is live. + const { doc, key } = await freshDoc("docs/f10status.md", "# F10 status\n\nA status target sentence here.\n"); + const api = await getApi(); + assert.strictEqual(api.trackChangesPreviewController.isOpen(key), false, "no panel for this doc"); + await propose(doc, key, "A status target sentence here.", "A STATUS replacement.", "turn-stat"); + await settle(); + const text = api.trackChangesPreviewController.statusText(); + assert.ok(text && text.length > 0, "the off-panel indicator shows a non-empty status"); + assert.match(text!, /1 Claude proposal/, "it mentions the pending count"); + + // open the preview for this doc → the off-panel indicator hides (undefined). + await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); + await settle(); + assert.strictEqual(api.trackChangesPreviewController.isOpen(key), true, "panel open"); + assert.strictEqual(api.trackChangesPreviewController.statusText(), undefined, "indicator hidden once the panel is open"); + }); + + test("clean editor: data layers intact AND the retired in-editor surfaces are gone (INV-32)", async () => { + const { doc, key } = await freshDoc("docs/f10clean.md", "# F10 clean\n\nA clean target sentence here.\n"); + const api = await getApi(); + const id = await propose(doc, key, "A clean target sentence here.", "A CLAUDE clean replacement.", "turn-clean"); + await settle(); + assert.ok(await api.proposalController.acceptById("docs/f10clean.md", id), "accept lands the Claude edit"); + await settle(); + // data layer intact: a Claude (agent) attribution span exists. + assert.ok( + api.attributionController.getSpans("docs/f10clean.md").some((s) => s.authorKind === "agent"), + "agent span recorded (attribution data layer intact)", + ); + // the retired toggle is gone from the palette (no editor decorations — INV-32). + const all = await vscode.commands.getCommands(true); + assert.ok(!all.includes("cowriting.toggleAttribution"), "cowriting.toggleAttribution is retired"); + // the F6 diff toggle's keybinding is hidden (when:false) — it is not a user surface. + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); + const dKb = (pkg.contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find( + (k) => k.command === "cowriting.toggleDiffView" && k.key === "ctrl+alt+d", + ); + assert.ok(dKb, "the ctrl+alt+d toggleDiffView keybinding is declared"); + assert.strictEqual(dKb!.when, "false", "…but hidden (when:false) — F6 is a data layer, not a user surface"); + void key; + }); +}); + +// ---- helpers reused across the order-dependent main-flow tests ---- + +function byKey(key: string): vscode.TextDocument | undefined { + return vscode.workspace.textDocuments.find((d) => d.uri.toString() === key); +} +async function reopen(rel: string): Promise<{ doc: vscode.TextDocument; key: string }> { + const uri = vscode.Uri.file(path.join(WS, rel)); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + await settle(); + return { doc, key: uri.toString() }; +}