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
@@ -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;