* 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>
334 lines
14 KiB
TypeScript
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();
|
|
}
|
|
}
|