Files
openchamber/packages/vscode/src/InlineCommentThreads.ts
T
Felipe GenéandBohdan Triapitsyn a12b9be443 feat(chat): Comments and review in VS Code, like the OpenChamber desktop app (#1724)
* feat(chat): render code comments as cards instead of fenced text

* fix(vscode): route Add Comment to the active session editor panel

* fix(chat): persist queued inline comments and tighten file-chip path matching

* feat(vscode): comment on code from the editor

* fix(chat): keep attached context in the message and broadcast comment removal

* fix(vscode): hold every pending comment and gate both entry points on the workspace

* fix(vscode): let only the owning surface decide its comment threads

* fix(vscode): drop a comment removed while its delivery was still in flight

* test(vscode): cover the in-flight comment removal guard

* test(vscode): cover comment removal reaching every chat surface

* fix(vscode): give up on a comment the chat never confirmed holding

* fix(vscode): retract a comment everywhere before reporting it discarded

* fix(chat): preserve queued comment cards

* fix: preserve inline comment context across send paths

* fix(chat): preserve command routing with context

* fix(chat): keep unavailable actions on normal send path

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-09-05 12:28:17 +03:00

334 lines
14 KiB
TypeScript

/**
* Code comments written in the editor itself.
*
* A comment is written while looking at the code it is about, so it is captured
* where the code is: right-click a selection (or use the gutter `+`) and a
* thread opens on those lines. The thread stays anchored there, showing what
* will be sent, until the message goes out or the comment is dropped.
*
* The composer's chips remain the list of what is attached. This module is the
* editor-side view of that same list, which is why it never owns a draft: it
* mints the id, hands the draft to the webview, and disposes its thread when
* the webview reports the draft gone. The webview store stays authoritative,
* so a comment removed from the chip row cannot linger in the editor.
*/
import * as vscode from 'vscode';
import { DELIVERY_CONFIRMATION_TIMEOUT_MS, canCommentOnDocument, nextDraftId, reconcileThreadFate, resolveCommentFilePath, resolveCommentOrigin, selectionLineRange, shouldAbandonUnconfirmed, shouldDisposeOnEmptyBody, snapshotOwnsThread, type CommentOrigin, type LineRange } from './inlineCommentSelection';
// Also written literally in package.json, which gates the thread menus with
// `commentController == openchamber.inlineComments`. JSON cannot import, so the
// two have to be kept in step by hand.
const INLINE_COMMENT_CONTROLLER_ID = 'openchamber.inlineComments';
export interface InlineCommentDraftPayload {
draftId: string;
filePath: string;
relativePath: string;
source: 'diff' | 'file';
side?: 'original' | 'modified';
startLine: number;
endLine: number;
code: string;
language: string;
comment: string;
}
interface OpenChamberCommentThread extends vscode.CommentThread {
draftId?: string;
/** Diff identity captured while the thread's editor is authoritative. */
commentOrigin?: CommentOrigin;
/** Last body written to this thread, so reconciliation can skip no-op renders. */
commentBody?: string;
/**
* Whether the composer has ever reported holding this draft.
*
* Delivery is asynchronous, so a snapshot can arrive describing the moment
* before the draft landed. Absence only means "removed" once presence has
* been seen at least once.
*/
confirmed?: boolean;
/**
* The chat webview holding this comment's draft.
*
* Only that surface's snapshots can decide this thread's fate; every other
* webview has its own store where the draft never existed.
*/
surfaceId?: string;
/** Deadline for the composer to confirm it holds this draft. */
confirmationTimer?: ReturnType<typeof setTimeout>;
}
/** Identifies one chat webview: a session panel id, or the sidebar. */
export const SIDEBAR_SURFACE_ID = 'sidebar';
export interface InlineCommentThreadsOptions {
/**
* Hands a finished draft to a chat webview.
*
* Returns the id of the surface that accepted it, or null when none did.
* The identity matters: only that surface's later snapshots can speak for
* this comment, because every webview holds its own draft store.
*/
submitDraft: (payload: InlineCommentDraftPayload) => Promise<string | null> | string | null;
/** Asks the webview to drop a draft the user removed from the editor side. */
removeDraft: (draftId: string) => void;
/** Tells the user a comment never reached the composer and was given up on. */
reportUndelivered: () => void;
/** The extension's own icon, shown as the comment's avatar. */
avatar: vscode.Uri;
/** Localized strings, injected so this module does not reach for the l10n bundle. */
strings: {
threadLabel: (range: LineRange) => string;
author: string;
notSent: string;
};
}
/**
* Owns the comment controller and every thread currently on screen.
*
* Threads are keyed by draft id once submitted. Before submission a thread has
* no draft yet, so it is tracked only by the controller and disposed on cancel.
*/
export class InlineCommentThreads implements vscode.Disposable {
private readonly controller: vscode.CommentController;
private readonly threadsByDraftId = new Map<string, OpenChamberCommentThread>();
private readonly options: InlineCommentThreadsOptions;
constructor(options: InlineCommentThreadsOptions) {
this.options = options;
this.controller = vscode.comments.createCommentController(
INLINE_COMMENT_CONTROLLER_ID,
'OpenChamber',
);
// Any line of a workspace file can take a comment; the gutter `+`
// follows from this.
this.controller.commentingRangeProvider = {
provideCommentingRanges: (document) => {
if (!this.canCommentOn(document.uri)) return [];
return [new vscode.Range(0, 0, Math.max(document.lineCount - 1, 0), 0)];
},
};
}
/**
* Whether this document can take a comment.
*
* Both entry points ask, so the gutter `+` and the right-click command
* agree: a comment is filed against a workspace-relative path, and one
* written outside the workspace would name a file that does not resolve.
*/
public canCommentOn(uri: vscode.Uri): boolean {
const filePath = resolveCommentFilePath(uri.fsPath, uri.query);
const inWorkspace = Boolean(vscode.workspace.getWorkspaceFolder(vscode.Uri.file(filePath)));
return canCommentOnDocument(uri.scheme, inWorkspace);
}
/** Opens an empty thread on a selection, with the reply box focused. */
public openThread(uri: vscode.Uri, range: vscode.Range): vscode.CommentThread {
const lines = selectionLineRange(range);
// SAFETY: this controller creates and owns the thread; the added fields
// are optional extension-local bookkeeping on VS Code's mutable object.
const thread = this.controller.createCommentThread(uri, range, []) as OpenChamberCommentThread;
thread.commentOrigin = this.resolveOrigin(uri);
thread.label = this.options.strings.threadLabel(lines);
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded;
thread.canReply = true;
thread.contextValue = 'openchamberPending';
return thread;
}
/**
* Turns a typed reply into a draft the composer will send.
*
* An empty body is a cancel: the thread is disposed rather than left behind
* as a comment that will never be sent.
*/
public async submitReply(reply: { thread: vscode.CommentThread; text: string }): Promise<void> {
// SAFETY: this command is registered only for threads created by this
// controller, which are initialized as OpenChamberCommentThread above.
const thread = reply.thread as OpenChamberCommentThread;
if (shouldDisposeOnEmptyBody(reply.text)) {
this.disposeThread(thread);
return;
}
// A thread whose range the editor dropped (the file was closed or edited
// out from under it) has nothing to anchor a comment to.
const range = thread.range;
if (!range) {
this.disposeThread(thread);
return;
}
// Capture before the first await. Opening a git document can yield long
// enough for tab focus to move, while the thread still belongs to the
// diff pane where the user submitted it.
const origin = thread.commentOrigin ?? this.resolveOrigin(thread.uri);
thread.commentOrigin = origin;
const document = await vscode.workspace.openTextDocument(thread.uri);
const lines = selectionLineRange(range);
const draftId = nextDraftId(Date.now(), Math.random());
// The gutter `+` produces a thread VS Code created, which never went
// through openThread and so carries no label. Set it here so both entry
// points read the same.
thread.label = this.options.strings.threadLabel(lines);
// Quote the pane the user commented on, but name the real file: a diff's
// original side is a `git:` document, and its raw path is not something
// the composer can match against the workspace.
const filePath = resolveCommentFilePath(thread.uri.fsPath, thread.uri.query);
const fileUri = vscode.Uri.file(filePath);
const payload: InlineCommentDraftPayload = {
draftId,
filePath,
relativePath: vscode.workspace.asRelativePath(fileUri, false),
...origin,
startLine: lines.startLine,
endLine: lines.endLine,
code: document.getText(range),
language: document.languageId,
comment: reply.text,
};
const surfaceId = await this.options.submitDraft(payload);
if (!surfaceId) {
// Nothing took the draft (no chat surface open). Leaving the thread
// would promise an attachment that does not exist.
this.disposeThread(thread);
return;
}
thread.surfaceId = surfaceId;
thread.draftId = draftId;
thread.commentBody = reply.text;
thread.canReply = false;
thread.contextValue = 'openchamberAttached';
thread.comments = [this.buildComment(reply.text)];
this.threadsByDraftId.set(draftId, thread);
// Accepting the draft is not the same as it landing. A panel whose
// webview never boots leaves this thread showing "Not sent yet" for a
// comment that will never be sent and cannot be rewritten, so it is
// given up on rather than left as a standing promise.
thread.confirmationTimer = setTimeout(() => {
thread.confirmationTimer = undefined;
if (!shouldAbandonUnconfirmed(thread.confirmed)) return;
// Retract it everywhere before saying it was discarded. Dropping only
// the thread leaves the payload in a panel's hold, so a webview that
// boots after the deadline would still file the draft and send a
// comment the user was just told had been thrown away.
this.options.removeDraft(draftId);
this.disposeThread(thread);
this.options.reportUndelivered();
}, DELIVERY_CONFIRMATION_TIMEOUT_MS);
}
private resolveOrigin(uri: vscode.Uri): CommentOrigin {
const activeTabInput = vscode.window.tabGroups.activeTabGroup.activeTab?.input;
if (activeTabInput instanceof vscode.TabInputTextDiff) {
return resolveCommentOrigin(uri.toString(), uri.scheme, {
original: activeTabInput.original.toString(),
modified: activeTabInput.modified.toString(),
});
}
return resolveCommentOrigin(uri.toString(), uri.scheme);
}
/**
* Brings the editor threads in line with what the composer actually holds.
*
* The webview sends its whole current draft list rather than individual
* events, so a dropped or reordered notification cannot leave a thread
* anchored to a comment that will never be sent. Sending the message empties
* the list, which clears every thread through the same path.
*
* Only threads this controller created are ever touched, so an unknown id in
* the snapshot (a comment written in the in-app file viewer) is ignored
* rather than treated as something to reconcile.
*
* A snapshot speaks only for the surface that sent it. Every webview keeps
* its own draft store, so a second session tab reporting an empty list says
* nothing about a comment attached to the first one.
*/
public reconcile(surfaceId: string, drafts: ReadonlyArray<{ id: string; text: string }>): void {
const byId = new Map(drafts.map((draft) => [draft.id, draft.text]));
for (const [draftId, thread] of [...this.threadsByDraftId]) {
if (!snapshotOwnsThread(thread.surfaceId, surfaceId)) continue;
const text = byId.get(draftId);
const fate = reconcileThreadFate(text, Boolean(thread.confirmed));
if (fate === 'wait') continue;
if (fate === 'dispose') {
this.disposeThread(thread);
continue;
}
thread.confirmed = true;
if (thread.confirmationTimer) {
clearTimeout(thread.confirmationTimer);
thread.confirmationTimer = undefined;
}
if (text !== undefined && thread.commentBody !== text) {
thread.commentBody = text;
thread.comments = [this.buildComment(text)];
}
}
}
/**
* Removes a thread from the editor side.
*
* A thread that already carries a draft has to tell the composer, or the
* chip would stay attached with nothing shown in the code.
*/
public removeThread(thread: vscode.CommentThread): void {
// SAFETY: removeThread is wired only to this controller's comment menu.
const draftId = (thread as OpenChamberCommentThread).draftId;
if (draftId) {
this.options.removeDraft(draftId);
}
this.disposeThread(thread);
}
public dispose(): void {
for (const thread of this.threadsByDraftId.values()) {
if (thread.confirmationTimer) clearTimeout(thread.confirmationTimer);
}
this.threadsByDraftId.clear();
this.controller.dispose();
}
private buildComment(body: string): vscode.Comment {
// The body is the user's own prose, not a document: rendering it as
// Markdown would eat underscores and asterisks they meant literally,
// and a comment on code is full of both.
const rendered = new vscode.MarkdownString();
rendered.appendText(body);
return {
body: rendered,
mode: vscode.CommentMode.Preview,
author: { name: this.options.strings.author, iconPath: this.options.avatar },
label: this.options.strings.notSent,
contextValue: 'openchamberAttached',
};
}
private disposeThread(thread: OpenChamberCommentThread): void {
if (thread.confirmationTimer) {
clearTimeout(thread.confirmationTimer);
thread.confirmationTimer = undefined;
}
if (thread.draftId) {
this.threadsByDraftId.delete(thread.draftId);
}
thread.dispose();
}
}