/** * ThreadController — the thin editor-facing layer (spec §6.4). Wires * CoauthorStore + Anchorer + threadModel to vscode.comments: create-on- * selection, reply, resolve, render, live-range tracking, reload, and external * re-anchoring (SLICE-4) — driven by the SHARED sidecar watcher in extension.ts * via handleExternalSidecarChange (self-writes are suppressed in CoauthorStore). * Threads are NEVER silently moved — an unresolvable anchor renders as an * orphaned thread (INV-1). */ import * as vscode from "vscode"; import { SidecarRouter, docIdentity } from "./sidecarRouter"; import { emptyArtifact, type Artifact, type Provenance } from "./model"; import { buildFingerprint, resolve, shift, type OffsetRange } from "./anchorer"; import { gitUserEmail } from "./identity"; import { addThread, appendMessage, setStatus } from "./threadModel"; import type { VersionGuard } from "./versionGuard"; import { isAuthorable } from "./workspacePath"; import type { CoeditingRegistry } from "./coeditingRegistry"; import type { EditFlow, EditTarget } from "./editFlow"; import type { LiveProgressUi } from "./liveProgressUi"; import type { EditTurnResult, RunEditTurnOptions } from "./liveTurn"; /** Test-facing snapshot of what is currently rendered for a document. */ export interface RenderedThread { id: string; status: "open" | "resolved"; orphaned: boolean; range: { startLine: number; endLine: number }; /** Finding 2 (final whole-branch review): the raw `contextValue` VS Code's * `comments/commentThread/*` `when`-clauses key on — exposed directly so * E2E can assert the status token survives an offer transition without a * DOM/UI query. */ contextValue: string; } /** D10/PUC-8: every human comment on a coedited doc runs this reply turn. */ const REPLY_PROMPT = (ask: string): string => "Reply conversationally (2-4 sentences) to this remark about the text, then, if the remark implies " + "an edit, state the edit you would make. Remark: " + ask; /** F6.x/Task 6: the injectable low-level turn runner behind respondInThread. */ type TurnRunner = (instruction: string, context: string, opts?: RunEditTurnOptions) => Promise; interface DocState { docPath: string; uri: vscode.Uri; artifact: Artifact; /** thread id -> vscode thread. */ vsThreads: Map; /** thread id -> live offset range (within-session optimization, INV-3). */ live: Map; /** thread id -> orphaned flag for the current render. */ orphaned: Map; /** Finding 2 (final whole-branch review): thread id -> in-flight offer-flow * token ("offer" once a machine reply lands, "offerdone" once spent), * tracked SEPARATELY from the open/resolved/orphaned status so setting one * never clobbers the other — both are folded into one space-separated * `contextValue` string by `applyContextValue` (package.json when-clauses * match either token with an unanchored regex). Ephemeral: reset on every * render (a fresh vsThread never carries a stale offer). */ offerToken: Map; } export class ThreadController implements vscode.Disposable { private readonly controller: vscode.CommentController; private readonly disposables: vscode.Disposable[] = []; private readonly docs = new Map(); // keyed by docPath /** thread -> the pending machine "offer" (D8): the ask it answered, ready to become an edit. */ private readonly offers = new WeakMap(); /** Test seam (Task 6): stub the reply-loop turn so E2E never spawns a real @cline/sdk agent. */ private turnRunner: TurnRunner | undefined; /** Kept as ONE object; re-assigned on every registry change so VS Code re-queries * (spec §6.4 v0.2.1 — reassignment is the API's only "ranges changed" signal). */ private readonly rangeProvider: vscode.CommentingRangeProvider = { provideCommentingRanges: (document) => { if (!isAuthorable(document.uri.scheme)) return []; if (!this.registry.isCoediting(document.uri)) return []; return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)]; }, }; constructor( private readonly store: SidecarRouter, private readonly rootDir: string | undefined, private readonly guard: VersionGuard, private readonly registry: CoeditingRegistry, private readonly editFlow: EditFlow, private readonly liveProgressUi: LiveProgressUi, ) { this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads"); this.controller.commentingRangeProvider = this.rangeProvider; this.disposables.push(this.controller); this.disposables.push( vscode.commands.registerCommand("cowriting.createThread", () => this.createThreadOnSelection()), vscode.commands.registerCommand("cowriting.reply", (r: vscode.CommentReply) => this.reply(r)), vscode.commands.registerCommand("cowriting.resolveThread", (t: vscode.CommentThread) => this.setThreadStatus(t, "resolved"), ), vscode.commands.registerCommand("cowriting.reopenThread", (t: vscode.CommentThread) => this.setThreadStatus(t, "open"), ), // D19 (spec §6.4 v0.2.1): the comments-first "Ask Claude" gesture — a // focused comment box, not a webview. vscode.commands.registerCommand("cowriting.askClaude", () => this.askClaude()), // D8: turn a machine "offer" thread into pending F4 proposal(s) (INV-5). vscode.commands.registerCommand( "cowriting.makeThreadEdit", (arg: { thread: vscode.CommentThread } | vscode.CommentThread) => this.makeEditFromThreadArg(arg), ), vscode.workspace.onDidChangeTextDocument((e) => this.onDidChange(e)), vscode.workspace.onDidSaveTextDocument((d) => this.onDidSave(d)), // INV-10 (PUC-7): force VS Code to re-query commenting ranges on every gate // change, and hide/restore rendered threads — exit hides only (dispose the // rendered vsThreads), re-enter restores (renderAll reloads from the // sidecar, which is the durable source of truth regardless of gate state). this.registry.onDidChange(({ uri, coediting }) => { this.controller.commentingRangeProvider = this.rangeProvider; const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); if (!doc) return; if (coediting) { this.renderAll(doc); } else { this.disposeRendered(this.keyOf(doc)); } }), ); } /** The single document key (F8): repo-relative path in-workspace, URI string otherwise. */ private keyOf(document: vscode.TextDocument): string { return this.store.keyOf(docIdentity(document)); } private currentAuthor(): Provenance { const id = vscode.workspace.getConfiguration("git").get("user.name") || process.env.USER || "human"; const email = this.rootDir !== undefined ? gitUserEmail(this.rootDir) : undefined; return { kind: "human", id, ...(email !== undefined ? { email } : {}) }; } private persist(state: DocState): void { this.store.update(state.docPath, (a) => { a.threads = state.artifact.threads; for (const t of state.artifact.threads) { a.anchors[t.anchorId] = state.artifact.anchors[t.anchorId]; } }); } private ensureState(document: vscode.TextDocument): DocState { const docPath = this.keyOf(document); let state = this.docs.get(docPath); if (!state) { state = { docPath, uri: document.uri, artifact: this.store.load(docPath) ?? emptyArtifact(docPath), vsThreads: new Map(), live: new Map(), orphaned: new Map(), offerToken: new Map(), }; this.docs.set(docPath, state); } return state; } // ---- D19: comments-first ask ----------------------------------------------------- /** * D19 (spec §6.4 v0.2.1): "Ask Claude" opens a focused comment box on the * active editor's selection, or, when there is no selection, a top-anchored * whole-document thread. The ask itself IS a comment — respondInThread (D10) * takes it from there once the human submits it via cowriting.reply. */ async askClaude(): Promise { const ed = vscode.window.activeTextEditor; if (!ed || !this.registry.isCoediting(ed.document.uri)) { void vscode.window.showWarningMessage("Run ✦ Coedit this Document with Claude first."); return; } if (ed.selection.isEmpty) { // whole-document ask → top-anchored thread (D19) ed.selection = new vscode.Selection(0, 0, 0, 0); ed.revealRange(new vscode.Range(0, 0, 0, 0)); } // Spike finding: workbench.action.addComment is the only path that opens the // comment widget WITH its input focused (no thread.focus() API exists). this.controller.commentingRangeProvider = this.rangeProvider; // defensive refresh await vscode.commands.executeCommand("workbench.action.addComment"); } // ---- PUC-1: create on selection ------------------------------------------------- async createThreadOnSelection(firstBody = "New thread"): Promise { const editor = vscode.window.activeTextEditor; if (!editor || editor.selection.isEmpty || !isAuthorable(editor.document.uri.scheme)) return undefined; if (!this.registry.isCoediting(editor.document.uri)) return undefined; if (this.guard.isReadOnly(this.keyOf(editor.document))) return undefined; const document = editor.document; const state = this.ensureState(document); const offsets: OffsetRange = { start: document.offsetAt(editor.selection.start), end: document.offsetAt(editor.selection.end), }; const fp = buildFingerprint(document.getText(), offsets); const author = this.currentAuthor(); const { threadId } = addThread(state.artifact, fp, { author, body: firstBody }); this.persist(state); const vsThread = this.renderThread(document, state, threadId, offsets, false); // D10: the first message of a new thread is also "a comment on a coedited // doc" — run the same respond loop as a reply. Finding 2 guard: only for a // human-authored message (never re-trigger the loop off the machine's own // words — see reply() below for the matching guard). if (author.kind !== "agent") { void this.respondInThread(vsThread, state, threadId, firstBody); } return threadId; } // ---- PUC-2: reply / resolve ----------------------------------------------------- reply(r: vscode.CommentReply): void { const vsThread = r.thread; if (!isAuthorable(vsThread.uri.scheme) || !this.registry.isCoediting(vsThread.uri)) return; const document = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString()); if (!document) return; const state = this.ensureState(document); if (this.guard.isReadOnly(state.docPath)) return; const author = this.currentAuthor(); let threadId = this.threadIdOf(vsThread); if (threadId) { appendMessage(state.artifact, threadId, { author, body: r.text }); } else { // D10/D19: a brand-new thread created via the native "+"/comment-widget // gesture (not via createThreadOnSelection) — VS Code hands us a real, // still-unregistered CommentThread; register it now. const range = vsThread.range ?? new vscode.Range(0, 0, 0, 0); const offsets: OffsetRange = { start: document.offsetAt(range.start), end: document.offsetAt(range.end), }; const fp = buildFingerprint(document.getText(), offsets); const created = addThread(state.artifact, fp, { author, body: r.text }); threadId = created.threadId; state.vsThreads.set(threadId, vsThread); state.live.set(threadId, offsets); state.orphaned.set(threadId, false); // Finding 2 (final whole-branch review): a thread born via the native // "+" gesture skipped renderThread entirely, so it never got a status // contextValue at all — Resolve/Reopen never appeared for it. Stamp one // now, same as every other thread. this.applyContextValue(state, threadId, vsThread); } this.persist(state); this.refreshComments(vsThread, state, threadId); // D10: every HUMAN comment on a coedited doc runs a turn. Finding 2 guard: // `cowriting.reply` is the ingress for both a genuine human reply and (in // principle) a re-dispatched machine message — never loop the machine's own // reply back through respondInThread (it would double-turn on Claude's words // and re-flip an already-"offer" thread). if (author.kind !== "agent") { void this.respondInThread(vsThread, state, threadId, r.text); } } /** * D10/PUC-8: run one reply turn for `ask`, then append the machine's reply * (INV-8 onBehalfOf provenance) and flip the thread into an "offer" — ready * for makeThreadEdit to cut it into pending proposal(s) (D8, INV-5). */ private async respondInThread( vsThread: vscode.CommentThread, state: DocState, threadId: string, ask: string, ): Promise { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === vsThread.uri.toString()); if (!doc) return; const contextText = this.threadContextText(doc, vsThread); try { await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: "Cowriting: asking Claude…", cancellable: true }, async (progress, token) => { const run = this.turnRunner ?? (await import("./liveTurn")).runEditTurn; const ui = this.liveProgressUi.begin(ask, progress, token); let turn: EditTurnResult; try { turn = await run(REPLY_PROMPT(ask), contextText, { onProgress: ui.onProgress, signal: ui.signal }); } catch (err) { if (token.isCancellationRequested) return; throw err; } // machine reply, onBehalfOf provenance (INV-8) appendMessage(state.artifact, threadId, { author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: turn.model, sessionId: turn.sessionId, onBehalfOf: this.currentAuthor() }, }, body: turn.replacement, }); this.persist(state); this.refreshComments(vsThread, state, threadId); // Finding 2: fold "offer" in alongside the status token — see // `applyContextValue` — instead of clobbering it (when-key: // `commentThread =~ /\boffer\b/`). state.offerToken.set(threadId, "offer"); this.applyContextValue(state, threadId, vsThread); this.offers.set(vsThread, { ask, threadId }); }, ); } catch (err) { void vscode.window.showErrorMessage(`Cowriting: Claude reply failed — ${err instanceof Error ? err.message : String(err)}`); } } /** The passage a ranged thread discusses, or the whole document for a top-anchored (D19) thread. */ private threadContextText(doc: vscode.TextDocument, vsThread: vscode.CommentThread): string { const range = vsThread.range; if (range && !range.isEmpty) return doc.getText(range); return doc.getText(); } /** D19: a ranged thread targets its range; a top-anchored (whole-doc-ask) thread targets the document. */ private targetOf(doc: vscode.TextDocument, vsThread: vscode.CommentThread): EditTarget { const range = vsThread.range; if (!range || range.isEmpty) return { kind: "document" }; return { kind: "range", start: doc.offsetAt(range.start), end: doc.offsetAt(range.end) }; } // ---- D8: offer -> pending proposal(s) (INV-5) ------------------------------------- private async makeEditFromThreadArg(arg: { thread: vscode.CommentThread } | vscode.CommentThread): Promise { const vsThread = "thread" in arg ? arg.thread : arg; await this.runMakeEdit(vsThread); } private async runMakeEdit(vsThread: vscode.CommentThread): Promise { const offer = this.offers.get(vsThread); if (!offer || !this.registry.isCoediting(vsThread.uri)) return []; const doc = await vscode.workspace.openTextDocument(vsThread.uri); const target = this.targetOf(doc, vsThread); const ids = await this.editFlow.runEditAndPropose(doc, target, offer.ask); // Finding 2 (final whole-branch review): fold "offerdone" in alongside the // status token rather than clobbering it — see `applyContextValue`. const state = this.stateOfThread(vsThread); if (state) { state.offerToken.set(offer.threadId, "offerdone"); this.applyContextValue(state, offer.threadId, vsThread); } // Finding 3 (seam hygiene): drop the offer once it's spent — otherwise the // `makeThreadEdit(threadId, docPath)` test seam (or a stray second UI click, // now dead per contextValue but still a valid direct call) could re-run the // same edit a second time. A future offer on this thread is a fresh // respondInThread() call, which re-populates the WeakMap entry. this.offers.delete(vsThread); if (ids.length) { void vscode.window.showInformationMessage( `Cowriting: ${ids.length} pending change${ids.length === 1 ? "" : "s"} landed in the buffer — ✓ Keep / ✗ Reject there.`, ); } return ids; } /** Test seam (Task 6): run the offer for a thread without the UI button. */ async makeThreadEdit(threadId: string, docPath: string): Promise { const vsThread = this.docs.get(docPath)?.vsThreads.get(threadId); if (!vsThread) return []; return this.runMakeEdit(vsThread); } /** Test seam (Task 6): stub the reply-loop turn so E2E never spawns a real @cline/sdk agent. */ setTurnRunnerForTest(fn: TurnRunner): void { this.turnRunner = fn; } private setThreadStatus(vsThread: vscode.CommentThread, status: "open" | "resolved"): void { const threadId = this.threadIdOf(vsThread); const state = this.stateOfThread(vsThread); if (!threadId || !state) return; if (this.guard.isReadOnly(state.docPath)) return; setStatus(state.artifact, threadId, status); this.persist(state); vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; this.applyContextValue(state, threadId, vsThread); // preserves any in-flight offer token } // ---- PUC-3/4: render, reload, re-anchor ----------------------------------------- /** Load + (re)render every thread for a document at its resolved anchor (or orphaned). */ renderAll(document: vscode.TextDocument): void { if (!this.registry.isCoediting(document.uri)) return; const docPath = this.keyOf(document); const state = this.ensureState(document); // fresh artifact from disk (reload / external change) state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath); for (const vsThread of state.vsThreads.values()) vsThread.dispose(); state.vsThreads.clear(); state.live.clear(); state.orphaned.clear(); state.offerToken.clear(); const text = document.getText(); for (const thread of state.artifact.threads) { const fp = state.artifact.anchors[thread.anchorId]?.fingerprint; const resolved = fp ? resolve(text, fp) : "orphaned"; if (resolved === "orphaned") { const line = fp ? Math.min(fp.lineHint, Math.max(0, document.lineCount - 1)) : 0; const off = document.offsetAt(new vscode.Position(line, 0)); this.renderThread(document, state, thread.id, { start: off, end: off }, true); } else { this.renderThread(document, state, thread.id, resolved, false); } } } /** Shared-watcher entry point (extension.ts): a sidecar changed externally. */ handleExternalSidecarChange(uri: vscode.Uri): void { const p = uri.fsPath; for (const state of this.docs.values()) { if (this.store.sidecarPath(state.docPath) === p) { const doc = vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath); if (doc) this.renderAll(doc); } } } private onDidChange(e: vscode.TextDocumentChangeEvent): void { const state = this.docs.get(this.keyOf(e.document)); if (!state || state.live.size === 0) return; for (const change of e.contentChanges) { const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length }; for (const [id, range] of state.live) { const next = shift(range, edit); state.live.set(id, next); const vsThread = state.vsThreads.get(id); if (vsThread && !state.orphaned.get(id)) { vsThread.range = new vscode.Range(e.document.positionAt(next.start), e.document.positionAt(next.end)); } } } } private onDidSave(document: vscode.TextDocument): void { const state = this.docs.get(this.keyOf(document)); if (!state || state.live.size === 0) return; if (this.guard.isReadOnly(state.docPath)) return; const text = document.getText(); let changed = false; for (const thread of state.artifact.threads) { if (state.orphaned.get(thread.id)) continue; const range = state.live.get(thread.id); if (!range) continue; state.artifact.anchors[thread.anchorId] = { fingerprint: buildFingerprint(text, range) }; changed = true; } if (changed) this.persist(state); } // ---- rendering helpers ---------------------------------------------------------- private renderThread( document: vscode.TextDocument, state: DocState, threadId: string, offsets: OffsetRange, orphaned: boolean, ): vscode.CommentThread { const thread = state.artifact.threads.find((t) => t.id === threadId)!; const range = new vscode.Range(document.positionAt(offsets.start), document.positionAt(offsets.end)); const vsThread = this.controller.createCommentThread( document.uri, range, thread.messages.map((m) => this.toComment(m.body, m.author.id)), ); vsThread.label = orphaned ? "⚠ Orphaned thread (anchor not found)" : undefined; vsThread.state = thread.status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved; vsThread.collapsibleState = vscode.CommentThreadCollapsibleState.Collapsed; state.vsThreads.set(threadId, vsThread); state.live.set(threadId, offsets); state.orphaned.set(threadId, orphaned); state.offerToken.delete(threadId); // a freshly-created vsThread never carries a stale offer this.applyContextValue(state, threadId, vsThread); return vsThread; } /** * Finding 2 (final whole-branch review): `contextValue` is the ONE string * VS Code's `comments/commentThread/*` `when`-clauses can key on, but two * INDEPENDENT things need to show through it — the open/resolved/orphaned * status (Resolve/Reopen) and the offer-flow state (makeThreadEdit). Folding * both into one space-separated string (`"open offer"`, `"resolved"`, …) * with package.json `when`s matched by an unanchored `=~ /\btoken\b/` regex * lets either flip without erasing the other — setting the offer flag alone * used to stomp the status token entirely (every human comment now triggers * a reply, so every thread on a coedited doc lost its Resolve/Reopen menu * the moment the machine answered). */ private applyContextValue(state: DocState, threadId: string, vsThread: vscode.CommentThread): void { const orphaned = !!state.orphaned.get(threadId); const thread = state.artifact.threads.find((t) => t.id === threadId); const statusToken = orphaned ? "orphaned" : (thread?.status ?? "open"); const offerToken = state.offerToken.get(threadId); vsThread.contextValue = offerToken ? `${statusToken} ${offerToken}` : statusToken; } private refreshComments(vsThread: vscode.CommentThread, state: DocState, threadId: string): void { const thread = state.artifact.threads.find((t) => t.id === threadId)!; vsThread.comments = thread.messages.map((m) => this.toComment(m.body, m.author.id)); } private toComment(body: string, authorName: string): vscode.Comment { return { body: new vscode.MarkdownString(body), mode: vscode.CommentMode.Preview, author: { name: authorName }, }; } private threadIdOf(vsThread: vscode.CommentThread): string | undefined { for (const state of this.docs.values()) { for (const [id, t] of state.vsThreads) if (t === vsThread) return id; } return undefined; } private stateOfThread(vsThread: vscode.CommentThread): DocState | undefined { for (const state of this.docs.values()) { for (const t of state.vsThreads.values()) if (t === vsThread) return state; } return undefined; } /** PUC-7 (INV-10): exit hides only — dispose the rendered vsThreads for a doc * without touching its sidecar-backed artifact (re-enter restores via renderAll). */ private disposeRendered(docPath: string): void { const state = this.docs.get(docPath); if (!state) return; for (const vsThread of state.vsThreads.values()) vsThread.dispose(); state.vsThreads.clear(); state.live.clear(); state.orphaned.clear(); state.offerToken.clear(); } // ---- test-facing surface -------------------------------------------------------- getRendered(docPath: string): RenderedThread[] { const state = this.docs.get(docPath); if (!state) return []; const out: RenderedThread[] = []; for (const [id, vsThread] of state.vsThreads) { const t = state.artifact.threads.find((x) => x.id === id)!; const r = vsThread.range; out.push({ id, status: t.status, orphaned: !!state.orphaned.get(id), range: { startLine: r ? r.start.line : 0, endLine: r ? r.end.line : 0 }, contextValue: typeof vsThread.contextValue === "string" ? vsThread.contextValue : "", }); } return out; } dispose(): void { for (const d of this.disposables) d.dispose(); } }