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>
This commit is contained in:
Felipe Gené
2026-09-05 12:28:17 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent d323b51a0a
commit a12b9be443
35 changed files with 2192 additions and 131 deletions
+36
View File
@@ -9,6 +9,7 @@ import { openSseProxy } from './sseProxy';
import { resolveWebviewDevServerUrl } from './webviewDevServer';
import { normalizeWindowsDriveLetter } from './pathUtils';
import { resolveWorkspaceFolders, type WorkspaceFolderCandidate } from './workspaceResolver';
import { SIDEBAR_SURFACE_ID } from './InlineCommentThreads';
type ActiveEditorFilePayload = {
filePath: string;
@@ -150,6 +151,19 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
return;
}
// Editor comment threads mirror the composer's drafts, so the webview
// reports every change. Handled before the id check because this is a
// one-way notification, not a bridge request awaiting a response.
if (message.type === 'inlineComments:sync') {
// Tagged with the sidebar's identity: a snapshot only speaks for the
// store that produced it, and each session panel has its own.
void vscode.commands.executeCommand('openchamber.internal.inlineCommentsSync', {
snapshot: message.payload,
surfaceId: SIDEBAR_SURFACE_ID,
});
return;
}
if (!('id' in message) || typeof message.id !== 'string') {
return;
}
@@ -249,6 +263,28 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
});
}
public addLineComment(payload: { draftId?: string; filePath: string; relativePath: string; source: 'diff' | 'file'; side?: 'original' | 'modified'; startLine: number; endLine: number; code: string; language: string; comment: string }) {
if (!this._view) return;
// Bring the chat into view like the other capture flows do, so the chip the
// comment becomes is visible rather than waiting behind a collapsed panel.
this._view.show(true);
this._view.webview.postMessage({
type: 'command',
command: 'addLineComment',
payload,
});
}
/** Drops a draft the user removed from its editor thread. */
public removeLineComment(draftId: string) {
if (!this._view) return;
this._view.webview.postMessage({
type: 'command',
command: 'removeLineComment',
payload: { draftId },
});
}
public addFileAttachments(files: Array<{ filePath: string; fileName: string; fileSize: number | null }>) {
if (!this._view) {
return;
+7
View File
@@ -82,6 +82,13 @@ The webview build emits each worker as one self-contained file. VS Code webviews
- Owns the persisted VS Code permission auto-accept policy and its GET/PUT bridge contract.
- Serializes reads and read-modify-write updates, persists a monotonic policy revision, and broadcasts the exact committed snapshot to every active OpenChamber webview. Permission replies remain foreground UI-owned because VS Code does not run the OpenChamber server runtime.
- `InlineCommentThreads.ts`
- Owns the `openchamber.inlineComments` comment controller: the gutter `+` range, the thread opened by `openchamber.addLineComment`, and every thread a submitted comment leaves anchored in the editor until the message goes out.
- A thread never owns a draft. It mints the draft id, hands the payload to a chat webview (the active or newly opened session panel, else the sidebar), and follows the webview's whole-draft-list `inlineComments:sync` snapshots: present means show, absent after having been seen means dispose. A snapshot is tagged with the surface that produced it (a panel id or `sidebar`) and only decides that surface's own threads, because every webview runs its own draft store.
- A comment the composer never confirms holding within 30 s is retracted from every surface's pending hold, its thread disposed, and the user told, so a thread cannot promise a send that will never happen.
- `inlineCommentSelection.ts` holds the pure pieces (line ranges, the diff-side and real-path resolution for `git:` documents, the pending hold, removal broadcast, thread fate) without the `vscode` import so they are unit-tested directly.
- Webview side: `webview/inlineCommentRemovals.ts` remembers removals that arrive before a delayed delivery lands, so a comment dropped while its panel was still booting does not appear as a chip later. The extension is not activated on startup for this; the right-click command activates it, and the gutter `+` appears from then on.
## Shared webview message ordering
Message and part ordering is owned by [`packages/ui/src/sync/DOCUMENTATION.md`](../../ui/src/sync/DOCUMENTATION.md#session-message-loading). The VS Code webview consumes that shared sync implementation; bridge and proxy runtimes pass OpenCode records through without adding runtime-specific ordering.
+333
View File
@@ -0,0 +1,333 @@
/**
* 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();
}
}
@@ -9,12 +9,40 @@ import { openSseProxy } from './sseProxy';
import { resolveWebviewDevServerUrl } from './webviewDevServer';
import { normalizeWindowsDriveLetter } from './pathUtils';
import { resolveWorkspaceFolders } from './workspaceResolver';
import { pickActivePanelId } from './activePanelRouting';
import { broadcastRemoval, drainPending } from './inlineCommentSelection';
const t = vscode.l10n.t;
type LineCommentPayload = {
draftId?: string;
filePath: string;
relativePath: string;
source: 'diff' | 'file';
side?: 'original' | 'modified';
startLine: number;
endLine: number;
code: string;
language: string;
comment: string;
};
type SessionPanelState = {
/** This panel's id, which is also its surface identity for comment threads. */
id: string;
panel: vscode.WebviewPanel;
sseStreams: Map<string, AbortController>;
/**
* Comments held until the webview proves it is listening. Posting into a
* panel whose script has not booted drops the message outright, and the user
* already saw the comment accepted.
*
* A list, because a second comment can be written while the panel is still
* starting; a single slot silently discarded the first.
*/
pendingLineComments: LineCommentPayload[];
/** Set by the panel's first inbound message, the only proof its script runs. */
webviewReady?: boolean;
};
type ActiveEditorFilePayload = {
@@ -119,8 +147,10 @@ export class SessionEditorPanelProvider {
};
const state: SessionPanelState = {
id: panelId,
panel,
sseStreams: new Map(),
pendingLineComments: [],
};
this._panels.set(panelId, state);
@@ -146,6 +176,29 @@ export class SessionEditorPanelProvider {
}, null, this._context.subscriptions);
panel.webview.onDidReceiveMessage(async (message: BridgeRequest) => {
// Any inbound message proves the webview script is running, which is the
// only readiness signal this panel has. Flush whatever was held for it.
state.webviewReady = true;
for (const pending of drainPending(state.pendingLineComments)) {
void panel.webview.postMessage({
type: 'command',
command: 'addLineComment',
payload: pending,
});
}
// Editor comment threads mirror the composer's drafts, so the webview
// reports every change. One-way notification, no response expected.
if (message.type === 'inlineComments:sync') {
// Tagged with this panel's identity: a snapshot only speaks for the
// store that produced it, and every panel has its own.
void vscode.commands.executeCommand('openchamber.internal.inlineCommentsSync', {
snapshot: message.payload,
surfaceId: panelId,
});
return;
}
if (message.type === 'restartApi') {
await this._openCodeManager?.restart();
return;
@@ -246,8 +299,10 @@ export class SessionEditorPanelProvider {
}
private _getActivePanelEntry(): SessionPanelState | null {
const activeEntry = Array.from(this._panels.entries()).find(([, entry]) => entry.panel.active);
const panelId = activeEntry?.[0] ?? this._lastActivePanelId;
const panelId = pickActivePanelId(
Array.from(this._panels.entries()).map(([id, entry]) => ({ id, active: entry.panel.active })),
this._lastActivePanelId,
);
if (!panelId) {
return null;
}
@@ -274,6 +329,105 @@ export class SessionEditorPanelProvider {
return true;
}
public addLineCommentToActivePanel(payload: {
draftId?: string;
filePath: string;
relativePath: string;
source: 'diff' | 'file';
side?: 'original' | 'modified';
startLine: number;
endLine: number;
code: string;
language: string;
comment: string;
}): string | null {
if (!payload.relativePath.trim()) {
return null;
}
const entry = this._getActivePanelEntry();
if (!entry) {
return null;
}
entry.panel.reveal(entry.panel.viewColumn ?? vscode.ViewColumn.Active, true);
// An existing panel can still be booting (reopened from a restored window),
// and a post into a webview whose script has not run is dropped outright.
// Hold it on the same path a freshly opened panel uses.
if (!entry.webviewReady) {
entry.pendingLineComments.push(payload);
return entry.id;
}
void entry.panel.webview.postMessage({
type: 'command',
command: 'addLineComment',
payload,
});
return entry.id;
}
/**
* Delivers a comment to a session tab, opening one when none exists.
*
* A comment is written against code the user is reading, so it must not
* depend on their having opened a chat first. With no tab open this behaves
* like the toolbar's new-session button, then delivers into that tab once its
* webview is listening.
*/
public openWithLineComment(payload: LineCommentPayload, activeSessionId: string | null): string | null {
if (!payload.relativePath.trim()) {
return null;
}
const accepted = this.addLineCommentToActivePanel(payload);
if (accepted) {
return accepted;
}
if (activeSessionId) {
this.createOrShow(activeSessionId);
} else {
this.createOrShowNewSession();
}
const entry = this._getActivePanelEntry();
if (!entry) {
return null;
}
entry.pendingLineComments.push(payload);
return entry.id;
}
/**
* Drops a draft the user removed from its editor thread.
*
* Sent to every panel, not just the active one: each webview owns its own
* draft store, and the draft may have landed in a tab the user has since
* moved away from. Targeting only the active panel made removal a silent
* no-op in that case, leaving the chip attached after its thread was gone.
*
* Unlike adding, this does not reveal a panel: the user is looking at the
* code, and stealing focus to show a chip disappearing would be worse than
* letting it disappear quietly.
*/
public removeLineComment(draftId: string): void {
const targets = [...this._panels.values()].map((state) => ({
pendingLineComments: state.pendingLineComments,
notify: () => {
void state.panel.webview.postMessage({
type: 'command',
command: 'removeLineComment',
payload: { draftId },
});
},
}));
broadcastRemoval(targets, draftId);
}
public createSessionWithPromptInActivePanel(prompt: string): boolean {
if (!prompt.trim()) {
return false;
@@ -0,0 +1,39 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { pickActivePanelId } from './activePanelRouting';
describe('pickActivePanelId', () => {
test('returns null when there are no panels and no recent panel', () => {
assert.equal(pickActivePanelId([], null), null);
});
test('falls back to the last active panel when none is currently focused', () => {
// A chat panel exists but the user is focused elsewhere (e.g. the code editor).
const panels = [{ id: 'ses_a', active: false }];
assert.equal(pickActivePanelId(panels, 'ses_a'), 'ses_a');
});
test('prefers the currently focused panel over the last active one', () => {
const panels = [
{ id: 'ses_a', active: false },
{ id: 'ses_b', active: true },
];
assert.equal(pickActivePanelId(panels, 'ses_a'), 'ses_b');
});
test('uses the focused panel even when there is no recorded last active panel', () => {
assert.equal(pickActivePanelId([{ id: 'ses_b', active: true }], null), 'ses_b');
});
test('returns the last active panel when no panel is focused', () => {
const panels = [
{ id: 'ses_a', active: false },
{ id: 'ses_b', active: false },
];
assert.equal(pickActivePanelId(panels, 'ses_b'), 'ses_b');
});
test('returns null when nothing is focused and there is no recent panel', () => {
assert.equal(pickActivePanelId([{ id: 'ses_a', active: false }], null), null);
});
});
+17
View File
@@ -0,0 +1,17 @@
/**
* Pure selection logic shared by the session editor panel routing
* (`*ToActivePanel` methods). Kept free of the `vscode` dependency so it can be
* unit tested in isolation.
*
* A right-click command targets the panel the user is currently in. We prefer a
* panel that is actively focused; otherwise we fall back to the panel that was
* focused most recently. The caller is responsible for confirming the returned
* id still maps to a live panel.
*/
export function pickActivePanelId(
panels: Array<{ id: string; active: boolean }>,
lastActivePanelId: string | null,
): string | null {
const active = panels.find((panel) => panel.active);
return active?.id ?? lastActivePanelId ?? null;
}
+104
View File
@@ -6,8 +6,21 @@ import { createOpenCodeManager, type OpenCodeManager } from './opencode';
import { startGlobalEventWatcher, stopGlobalEventWatcher, setChatViewProvider } from './sessionActivityWatcher';
import { pathsEqualWithNormalizedDriveLetter } from './pathUtils';
import { resolveWorkspaceFolders } from './workspaceResolver';
import { InlineCommentThreads, SIDEBAR_SURFACE_ID } from './InlineCommentThreads';
let chatViewProvider: ChatViewProvider | undefined;
/** The webview's `{ drafts: [{ id, text }] }` snapshot, or null when it is not one. */
function readDraftSnapshot(snapshot: unknown): Array<{ id: string; text: string }> | null {
if (typeof snapshot !== 'object' || snapshot === null || !('drafts' in snapshot) || !Array.isArray(snapshot.drafts)) return null;
const drafts: Array<{ id: string; text: string }> = [];
for (const entry of snapshot.drafts) {
if (typeof entry !== 'object' || entry === null || !('id' in entry) || typeof entry.id !== 'string') continue;
const text = 'text' in entry && typeof entry.text === 'string' ? entry.text : '';
drafts.push({ id: entry.id, text });
}
return drafts;
}
let agentManagerProvider: AgentManagerPanelProvider | undefined;
let sessionEditorProvider: SessionEditorPanelProvider | undefined;
let openCodeManager: OpenCodeManager | undefined;
@@ -471,6 +484,97 @@ export async function activate(context: vscode.ExtensionContext) {
})
);
// Comments are written where the code is: the thread opens on the selected
// lines and stays there until the message is sent. The composer chips remain
// the authoritative list, so the threads follow what the webview reports.
const inlineCommentThreads = new InlineCommentThreads({
submitDraft: async (payload) => {
// A comment is written against code the user is reading, so it cannot
// require them to have opened a chat first: with no session tab open,
// one is opened, exactly as the toolbar's new-session button does.
const panelId = sessionEditorProvider?.openWithLineComment(payload, activeSessionId);
if (panelId) {
return panelId;
}
// No session editor at all (provider gone): fall back to the sidebar
// rather than accepting a comment that has nowhere to land.
if (!(await revealChatViewForPayload())) {
return null;
}
if (!chatViewProvider) {
vscode.window.showWarningMessage(t('OpenChamber: Chat sidebar is not ready'));
return null;
}
chatViewProvider.addLineComment(payload);
return SIDEBAR_SURFACE_ID;
},
removeDraft: (draftId) => {
// Every surface is told, because each webview holds its own draft store
// and only the one actually holding the draft can drop it. Removal is
// idempotent everywhere else.
sessionEditorProvider?.removeLineComment(draftId);
chatViewProvider?.removeLineComment(draftId);
},
reportUndelivered: () => {
vscode.window.showWarningMessage(t('OpenChamber [Add Comment]: The comment never reached the chat and was discarded'));
},
avatar: vscode.Uri.joinPath(context.extensionUri, 'assets', 'app-icon.png'),
strings: {
threadLabel: ({ startLine, endLine }) => (startLine === endLine
? t('Comment on line {0}', String(startLine))
: t('Comment on lines {0}-{1}', String(startLine), String(endLine))),
author: t('OpenChamber'),
notSent: t('Not sent yet'),
},
});
context.subscriptions.push(inlineCommentThreads);
context.subscriptions.push(
vscode.commands.registerCommand('openchamber.addLineComment', () => {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showWarningMessage(t('OpenChamber [Add Comment]: No active editor'));
return;
}
// Same rule the gutter `+` follows, so the two entry points cannot
// disagree about where a comment is allowed.
if (!inlineCommentThreads.canCommentOn(editor.document.uri)) {
vscode.window.showWarningMessage(t('OpenChamber [Add Comment]: File is outside the workspace'));
return;
}
inlineCommentThreads.openThread(editor.document.uri, editor.selection);
})
);
// Invoked by the thread's own Comment button, and by the gutter `+` flow,
// which both arrive as a CommentReply carrying the typed text.
context.subscriptions.push(
vscode.commands.registerCommand('openchamber.submitLineComment', async (reply: vscode.CommentReply) => {
await inlineCommentThreads.submitReply(reply);
})
);
context.subscriptions.push(
vscode.commands.registerCommand('openchamber.removeLineComment', (thread: vscode.CommentThread) => {
inlineCommentThreads.removeThread(thread);
})
);
// The webview reports its whole draft list whenever it changes; the threads
// follow it. Not contributed in package.json: internal wiring, not a command
// a user should find in the palette.
context.subscriptions.push(
vscode.commands.registerCommand('openchamber.internal.inlineCommentsSync', (message: { snapshot: unknown; surfaceId: string }) => {
// The snapshot crossed the webview boundary as JSON; the surface id was
// stamped by the provider that received it, so an untagged snapshot
// cannot be attributed and is ignored rather than applied to threads it
// may know nothing about.
const drafts = readDraftSnapshot(message.snapshot);
if (!drafts || !message.surfaceId) return;
inlineCommentThreads.reconcile(message.surfaceId, drafts);
})
);
context.subscriptions.push(
vscode.commands.registerCommand('openchamber.newSession', async (directory?: unknown) => {
const candidates = resolveWorkspaceFolders(vscode.workspace.workspaceFolders ?? []);
@@ -0,0 +1,258 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { DELIVERY_CONFIRMATION_TIMEOUT_MS, broadcastRemoval, canCommentOnDocument, drainPending, dropPendingById, nextDraftId, reconcileThreadFate, resolveCommentFilePath, resolveCommentOrigin, selectionLineRange, shouldAbandonUnconfirmed, shouldDisposeOnEmptyBody, snapshotOwnsThread } from './inlineCommentSelection';
const selection = (startLine: number, startChar: number, endLine: number, endChar: number) => ({
start: { line: startLine, character: startChar },
end: { line: endLine, character: endChar },
});
describe('selectionLineRange', () => {
test('a caret with no selection covers its own line', () => {
assert.deepEqual(selectionLineRange(selection(0, 4, 0, 4)), { startLine: 1, endLine: 1 });
});
test('a partial selection on one line covers that line', () => {
assert.deepEqual(selectionLineRange(selection(11, 2, 11, 30)), { startLine: 12, endLine: 12 });
});
test('a multi-line selection covers every line it touches', () => {
assert.deepEqual(selectionLineRange(selection(4, 0, 7, 12)), { startLine: 5, endLine: 8 });
});
test('stopping at the start of the next line does not count that line', () => {
// Dragging past the end of line 12 lands at (12, 0) but shows nothing
// there, so the comment is on line 12 alone.
assert.deepEqual(selectionLineRange(selection(11, 0, 12, 0)), { startLine: 12, endLine: 12 });
});
test('a selection ending at column 0 of its own line is still that line', () => {
assert.deepEqual(selectionLineRange(selection(3, 0, 3, 0)), { startLine: 4, endLine: 4 });
});
});
describe('nextDraftId', () => {
test('matches the shared store id format', () => {
assert.match(nextDraftId(1735689600000, 0.123456789), /^icd-1735689600000-[a-z0-9]+$/);
});
test('different randomness yields different ids at the same instant', () => {
assert.notEqual(nextDraftId(1, 0.5), nextDraftId(1, 0.9));
});
});
describe('resolveCommentFilePath', () => {
test('an ordinary file keeps its path', () => {
assert.equal(resolveCommentFilePath('/repo/src/app.ts', ''), '/repo/src/app.ts');
});
test("a Source Control diff resolves to the query's real path", () => {
// The original side of a diff is a `git:` document; its path is not a
// file on disk, but the query names the file it came from.
const query = JSON.stringify({ path: '/repo/src/app.ts', ref: '~' });
assert.equal(resolveCommentFilePath('/repo/src/app.ts.git', query), '/repo/src/app.ts');
});
test('a malformed query falls back to the URI path', () => {
assert.equal(resolveCommentFilePath('/repo/src/app.ts', 'not json'), '/repo/src/app.ts');
});
test('a query without a usable path falls back to the URI path', () => {
assert.equal(resolveCommentFilePath('/repo/src/app.ts', JSON.stringify({ ref: '~' })), '/repo/src/app.ts');
assert.equal(resolveCommentFilePath('/repo/src/app.ts', JSON.stringify({ path: ' ' })), '/repo/src/app.ts');
});
});
describe('resolveCommentOrigin', () => {
const diff = {
original: 'git:/repo/src/app.ts?ref=HEAD',
modified: 'file:///repo/src/app.ts',
};
test('identifies both sides of the active diff', () => {
assert.deepEqual(resolveCommentOrigin(diff.original, 'git', diff), { source: 'diff', side: 'original' });
assert.deepEqual(resolveCommentOrigin(diff.modified, 'file', diff), { source: 'diff', side: 'modified' });
});
test('uses the URI scheme when the active tab is unavailable', () => {
assert.deepEqual(resolveCommentOrigin(diff.original, 'git'), { source: 'diff', side: 'original' });
assert.deepEqual(resolveCommentOrigin(diff.modified, 'file'), { source: 'file' });
});
});
describe('canCommentOnDocument', () => {
test('a workspace file can take a comment', () => {
assert.equal(canCommentOnDocument('file', true), true);
});
test("a diff's original side can too, since it resolves to a workspace file", () => {
assert.equal(canCommentOnDocument('git', true), true);
});
test('a file outside the workspace cannot', () => {
// The comment is filed against a workspace-relative path, so one written
// elsewhere would name a file the composer cannot resolve.
assert.equal(canCommentOnDocument('file', false), false);
});
test('a comment editor cannot comment on itself', () => {
assert.equal(canCommentOnDocument('comment', true), false);
});
});
describe('drainPending', () => {
test('every held comment is returned, in order', () => {
// A second comment can be written while a panel is still booting.
// Keeping only the newest silently dropped the first after its thread
// had already reported success.
const pending = ['first', 'second', 'third'];
assert.deepEqual(drainPending(pending), ['first', 'second', 'third']);
});
test('the hold is emptied, so a later flush delivers nothing twice', () => {
const pending = ['only'];
drainPending(pending);
assert.deepEqual(pending, []);
assert.deepEqual(drainPending(pending), []);
});
test('an empty hold drains to nothing', () => {
assert.deepEqual(drainPending([]), []);
});
});
describe('dropPendingById', () => {
const held = () => [{ draftId: 'a' }, { draftId: 'b' }, { draftId: 'c' }];
test('a comment removed before it was ever delivered is dropped from the hold', () => {
// Removing the thread while the panel is still booting used to leave the
// payload queued, so the draft landed after the user had dropped it.
const pending = held();
assert.equal(dropPendingById(pending, 'b'), true);
assert.deepEqual(pending.map((p) => p.draftId), ['a', 'c']);
});
test('an id that is not held leaves the queue untouched', () => {
const pending = held();
assert.equal(dropPendingById(pending, 'zzz'), false);
assert.deepEqual(pending.map((p) => p.draftId), ['a', 'b', 'c']);
});
test('an empty hold reports nothing dropped', () => {
assert.equal(dropPendingById([], 'a'), false);
});
});
describe('shouldAbandonUnconfirmed', () => {
test('a comment the composer never reported holding is given up on', () => {
// The panel's webview never booted, or the message was dropped. The
// thread would otherwise show "Not sent yet" forever for a comment that
// cannot be sent and cannot be rewritten.
assert.equal(shouldAbandonUnconfirmed(undefined), true);
assert.equal(shouldAbandonUnconfirmed(false), true);
});
test('a comment the composer confirmed holding is kept', () => {
assert.equal(shouldAbandonUnconfirmed(true), false);
});
test('the deadline outlasts the composer own wait for a directory', () => {
// The webview waits up to 10s for a directory before filing the draft,
// so a shorter deadline here would abandon comments that were fine.
assert.ok(DELIVERY_CONFIRMATION_TIMEOUT_MS > 10_000);
});
});
describe('broadcastRemoval', () => {
const surface = (draftIds: string[]) => {
const notified: number[] = [];
return {
pendingLineComments: draftIds.map((draftId) => ({ draftId })),
notify: () => notified.push(1),
notified,
};
};
test('every surface is told, because only one of them holds the draft', () => {
const a = surface([]);
const b = surface([]);
broadcastRemoval([a, b], 'icd-1');
assert.equal(a.notified.length, 1);
assert.equal(b.notified.length, 1);
});
test('a comment still held for a booting surface is dropped from the hold', () => {
// The notification alone would find nothing: an undelivered comment is
// in no store yet, and would land after the user removed its thread.
const holding = surface(['icd-1', 'icd-2']);
broadcastRemoval([holding], 'icd-1');
assert.deepEqual(holding.pendingLineComments.map((p) => p.draftId), ['icd-2']);
});
test('surfaces holding nothing keep their queues intact', () => {
const other = surface(['icd-9']);
broadcastRemoval([other], 'icd-1');
assert.deepEqual(other.pendingLineComments.map((p) => p.draftId), ['icd-9']);
});
test('no surfaces at all is not an error', () => {
assert.doesNotThrow(() => broadcastRemoval([], 'icd-1'));
});
});
describe('snapshotOwnsThread', () => {
test('the surface holding the draft speaks for its thread', () => {
assert.equal(snapshotOwnsThread('panel-a', 'panel-a'), true);
});
test('another tab says nothing about this thread', () => {
// Every webview has its own draft store, so a second session tab
// reporting an empty list is not evidence that this comment is gone.
// Before this rule, opening a tab disposed the other tab's threads.
assert.equal(snapshotOwnsThread('panel-a', 'panel-b'), false);
});
test('the sidebar does not speak for a panel, nor a panel for the sidebar', () => {
assert.equal(snapshotOwnsThread('panel-a', 'sidebar'), false);
assert.equal(snapshotOwnsThread('sidebar', 'panel-a'), false);
});
test('a thread with no surface yet is owned by nobody', () => {
assert.equal(snapshotOwnsThread(undefined, 'panel-a'), false);
assert.equal(snapshotOwnsThread('', 'panel-a'), false);
});
});
describe('reconcileThreadFate', () => {
test('a draft absent from the very first snapshot is still in flight', () => {
// Opening a session tab makes its webview publish before the comment
// that opened it has landed. Treating that as a removal destroyed the
// thread the user had just written.
assert.equal(reconcileThreadFate(undefined, false), 'wait');
});
test('a draft absent after having been seen was removed', () => {
assert.equal(reconcileThreadFate(undefined, true), 'dispose');
});
test('a draft present is shown, and counts as seen', () => {
assert.equal(reconcileThreadFate('fix this', false), 'show');
assert.equal(reconcileThreadFate('fix this', true), 'show');
});
test('a draft emptied in the composer drops its thread', () => {
assert.equal(reconcileThreadFate('', true), 'dispose');
assert.equal(reconcileThreadFate(' ', false), 'dispose');
});
});
describe('shouldDisposeOnEmptyBody', () => {
test('blank and whitespace-only bodies are a cancel', () => {
assert.equal(shouldDisposeOnEmptyBody(''), true);
assert.equal(shouldDisposeOnEmptyBody(' \n\t '), true);
});
test('any real text is kept', () => {
assert.equal(shouldDisposeOnEmptyBody(' fix this '), false);
});
});
@@ -0,0 +1,199 @@
/**
* Pure selection and identity logic for editor comment threads. Kept free of
* the `vscode` dependency so it can be unit tested in isolation.
*/
export interface LineRange {
startLine: number;
endLine: number;
}
interface SelectionLike {
start: { line: number; character: number };
end: { line: number; character: number };
}
/**
* The 1-based inclusive line range a selection covers, as a reader sees it.
*
* Dragging to the start of the next line selects a trailing newline but shows
* nothing on that line, so counting it would label a one-line comment as two
* and send a range that does not match the highlight.
*/
export function selectionLineRange(selection: SelectionLike): LineRange {
const startLine = selection.start.line + 1;
const spansLines = selection.end.line > selection.start.line;
const stopsAtLineStart = selection.end.character === 0 && spansLines;
const endLine = (stopsAtLineStart ? selection.end.line - 1 : selection.end.line) + 1;
return { startLine, endLine };
}
/**
* A draft id in the shared store's format.
*
* The extension mints it so the thread and the composer chip agree on identity
* without a round trip; the store accepts a caller-provided id for exactly this.
*/
export function nextDraftId(now: number, randomFraction: number): string {
return `icd-${now}-${randomFraction.toString(36).substring(2, 9)}`;
}
/** An empty body is a cancel, not a comment worth keeping on screen. */
export function shouldDisposeOnEmptyBody(body: string): boolean {
return body.trim().length === 0;
}
/**
* The real file a comment target refers to.
*
* A diff opened from Source Control shows one pane per side, and the original
* side is not a file on disk: it is a `git:` document carrying the real path in
* its JSON query. Labelling a comment with the raw URI path would name a file
* the composer cannot match, so the query wins when it has one.
*
* @param path the URI path (already query-free, as `fsPath` gives it)
* @param query the URI query, empty for ordinary files
*/
export function resolveCommentFilePath(path: string, query: string): string {
if (!query) return path;
try {
const parsed: { path?: string } = JSON.parse(query);
return parsed.path?.trim() ? parsed.path : path;
} catch {
return path;
}
}
export type CommentOrigin = {
source: 'diff' | 'file';
side?: 'original' | 'modified';
};
/** Preserves which side of an active diff supplied the selected code. */
export function resolveCommentOrigin(
uri: string,
scheme: string,
activeDiff?: { original: string; modified: string },
): CommentOrigin {
if (activeDiff?.original === uri) return { source: 'diff', side: 'original' };
if (activeDiff?.modified === uri) return { source: 'diff', side: 'modified' };
if (scheme === 'git') return { source: 'diff', side: 'original' };
return { source: 'file' };
}
/**
* Whether a document can take a comment.
*
* Both entry points ask this, so the gutter `+` and the right-click command
* agree about where commenting is allowed. A comment is filed against a
* workspace-relative path, so one written outside the workspace would name a
* file the composer cannot resolve.
*/
export function canCommentOnDocument(scheme: string, isInWorkspace: boolean): boolean {
if (scheme === 'comment') return false;
return isInWorkspace;
}
/**
* Empties a hold of comments waiting on a webview that had not booted.
*
* A hold is a list, not a single slot: a user can write a second comment while
* the panel is still starting, and keeping only the newest silently dropped the
* first after its thread had already reported success.
*/
export function drainPending<T>(pending: T[]): T[] {
return pending.splice(0, pending.length);
}
/**
* Removes a held comment the user dropped before it was ever delivered.
*
* A comment waiting on a booting webview is in no store yet, so asking that
* webview to remove it finds nothing. Without dropping the hold too, the draft
* would land after the user had already removed its thread.
*
* @returns whether a held comment was dropped
*/
export function dropPendingById<T extends { draftId?: string }>(pending: T[], draftId: string): boolean {
const index = pending.findIndex((entry) => entry.draftId === draftId);
if (index < 0) return false;
pending.splice(index, 1);
return true;
}
/**
* How long a submitted comment may go unconfirmed before it is given up on.
*
* Long enough to outlast a cold webview boot plus the composer's own wait for a
* directory, short enough that a thread does not sit there promising to send
* something that never will.
*/
export const DELIVERY_CONFIRMATION_TIMEOUT_MS = 30_000;
/**
* Whether a submitted comment should be abandoned once its deadline passes.
*
* Confirmation means the composer reported holding the draft. Without it the
* comment reached no store: the panel's webview never booted, or the message
* was dropped. Keeping the thread would show "Not sent yet" forever, for a
* comment that cannot be sent and cannot be rewritten — only deleted.
*/
export function shouldAbandonUnconfirmed(confirmed: boolean | undefined): boolean {
return !confirmed;
}
/** A chat surface that may be holding, or showing, a comment draft. */
export interface RemovalTarget {
/** Comments still waiting on this surface's webview to boot. */
pendingLineComments: Array<{ draftId?: string }>;
/** Asks this surface's webview to drop the draft from its store. */
notify: () => void;
}
/**
* Tells every surface to drop a comment, wherever it currently lives.
*
* Each webview owns its own draft store, so the one holding the draft cannot be
* known from here; every surface is told and the rest no-op. The hold is cleared
* before notifying, because a comment that has not been delivered yet is in no
* store for the notification to find, and would otherwise arrive afterwards as a
* chip the user had already dropped.
*/
export function broadcastRemoval(targets: Iterable<RemovalTarget>, draftId: string): void {
for (const target of targets) {
dropPendingById(target.pendingLineComments, draftId);
target.notify();
}
}
/**
* Whether a draft snapshot is authoritative for a thread.
*
* Every webview — the sidebar and each session tab — runs its own draft store
* and publishes its whole list. Only the surface that accepted a comment knows
* whether it still holds it; to any other surface the draft simply never
* existed. Letting a foreign snapshot decide disposed threads that were alive
* and about to be sent, which is what opening a second tab used to do.
*/
export function snapshotOwnsThread(threadSurfaceId: string | undefined, snapshotSurfaceId: string): boolean {
return Boolean(threadSurfaceId) && threadSurfaceId === snapshotSurfaceId;
}
/** What a draft-list snapshot says should happen to one editor thread. */
export type ThreadFate = 'wait' | 'dispose' | 'show';
/**
* Decides a thread's fate from the composer's current draft list.
*
* `confirmed` records whether the composer has ever reported holding this
* draft. Until it has, absence means the delivery is still in flight, not that
* the comment was removed: opening a session tab produces a first snapshot
* describing the composer as it was before the comment that opened it arrived.
*
* @param text the draft's text in the snapshot, or undefined when absent
*/
export function reconcileThreadFate(text: string | undefined, confirmed: boolean): ThreadFate {
if (text === undefined) return confirmed ? 'dispose' : 'wait';
if (shouldDisposeOnEmptyBody(text)) return 'dispose';
return 'show';
}