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
+155 -1
View File
@@ -1,5 +1,6 @@
import { createVSCodeAPIs } from './api';
import { onCommand, onThemeChange, proxyApiRequest, proxySessionMessageRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge';
import { createRemovalTombstones } from './inlineCommentRemovals';
import { onCommand, onThemeChange, postBridgeNotification, proxyApiRequest, proxySessionMessageRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge';
import { vscodeStreamPerfCount, vscodeStreamPerfMeasure, vscodeStreamPerfObserve } from './api/streamPerf';
import { extractBodyBase64, extractBodyText, extractJsonBody, hasInitBody } from './requestBodyTransport';
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
@@ -1329,6 +1330,159 @@ onCommand('addContextSelection', (payload) => {
});
});
// Comments dropped from their editor thread before the draft reached this
// store. See the module for why the window exists.
const removedComments = createRemovalTombstones();
onCommand('addLineComment', (payload) => {
// SAFETY: the payload crossed the extension boundary as JSON; every field is
// read as unknown here and trusted only after the checks below.
const record = payload as {
draftId?: unknown;
filePath?: unknown;
relativePath?: unknown;
source?: unknown;
side?: unknown;
startLine?: unknown;
endLine?: unknown;
code?: unknown;
language?: unknown;
comment?: unknown;
};
// The editor thread mints the id so it can track its own draft without a
// round trip. Absent when the comment came from anywhere else.
const draftId = typeof record.draftId === 'string' && record.draftId ? record.draftId : undefined;
const relativePath = typeof record.relativePath === 'string' ? record.relativePath : '';
const source = record.source === 'diff' ? 'diff' : 'file';
const side = record.side === 'original' || record.side === 'modified' ? record.side : undefined;
const startLine = typeof record.startLine === 'number' ? record.startLine : 1;
const endLine = typeof record.endLine === 'number' ? record.endLine : startLine;
const code = typeof record.code === 'string' ? record.code : '';
const language = typeof record.language === 'string' ? record.language : 'text';
const comment = typeof record.comment === 'string' ? record.comment.trim() : '';
if (!relativePath) {
console.warn('[openchamber] inline comment arrived without a path; dropping', record);
return;
}
void Promise.all([
import('@/sync/session-ui-store'),
import('@/stores/useDirectoryStore'),
import('@/stores/useInlineCommentDraftStore'),
]).then(async ([{ useSessionUIStore }, { useDirectoryStore }, { useInlineCommentDraftStore }]) => {
// Inline drafts are owned by runtime + directory + session. Both halves are
// read together, from one store snapshot: read apart, a session that
// finished loading between them would pair its key with the previous
// session's directory, and the draft would land under a key ChatInput never
// reads. Directory precedence matches the composer's own.
const resolveTarget = () => {
const sessionState = useSessionUIStore.getState();
const currentSessionId = sessionState.currentSessionId;
const sessionDirectory = currentSessionId ? sessionState.getDirectoryForSession(currentSessionId) : null;
const draftDirectory = sessionState.newSessionDraft?.open
? sessionState.newSessionDraft.bootstrapPendingDirectory ?? sessionState.newSessionDraft.directoryOverride ?? null
: null;
const directory = sessionDirectory ?? draftDirectory ?? useDirectoryStore.getState().currentDirectory;
return directory ? { directory, sessionKey: currentSessionId ?? 'draft' } : null;
};
// A comment can arrive before the chat surface has finished booting: the
// extension opens the sidebar and posts after a fixed delay, which a cold
// webview can outlast. Dropping the draft here loses a comment the user
// already wrote and already saw accepted in the editor, so wait for the
// target to land instead.
let target = resolveTarget();
for (let attempt = 0; !target && attempt < 40; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 250));
target = resolveTarget();
}
if (!target) {
console.warn('[openchamber] no directory resolved; dropping inline comment', { relativePath, startLine });
return;
}
// Checked after the wait, which is the window the removal can land in.
if (removedComments.consume(draftId)) {
return;
}
useInlineCommentDraftStore.getState().addDraft(target, {
id: draftId,
source,
fileLabel: relativePath,
startLine,
endLine,
side,
code,
language,
text: comment,
});
});
});
// The editor's comment threads mirror the composer's drafts, so every change to
// the draft store is reported as a whole snapshot. Sending the full list rather
// than add/remove events means a dropped notification cannot leave a thread
// anchored to a comment that is no longer attached; sending the message empties
// the list, which clears the threads through the same path.
void import('@/stores/useInlineCommentDraftStore').then(({ useInlineCommentDraftStore }) => {
let lastSignature = '';
const publish = (drafts: Record<string, Array<{ id: string; text: string }>>) => {
const flat = Object.values(drafts)
.flat()
.map((draft) => ({ id: draft.id, text: draft.text }));
const signature = JSON.stringify(flat);
if (signature === lastSignature) return;
lastSignature = signature;
postBridgeNotification('inlineComments:sync', { drafts: flat });
};
publish(useInlineCommentDraftStore.getState().drafts);
useInlineCommentDraftStore.subscribe((state) => publish(state.drafts));
});
onCommand('removeLineComment', (payload) => {
if (typeof payload !== 'object' || payload === null || !('draftId' in payload)) return;
const { draftId } = payload;
if (typeof draftId !== 'string' || !draftId) {
return;
}
// Recorded even when the draft is already here: the store removal below is
// the normal path, and this only matters when the draft has not landed yet.
removedComments.remember(draftId);
void Promise.all([
import('@/stores/useInlineCommentDraftStore'),
import('@/lib/runtime-switch'),
]).then(([{ useInlineCommentDraftStore }, { getRuntimeKey }]) => {
const state = useInlineCommentDraftStore.getState();
const runtimeKey = getRuntimeKey();
// The thread knows its draft id but not which target holds it. Search for
// the owning key, and only within the current runtime: `removeDraft`
// recomputes the key from the live runtime, so a target rebuilt from
// another runtime's key would delete from the wrong place.
for (const [key, drafts] of Object.entries(state.drafts)) {
if (!drafts.some((draft) => draft.id === draftId)) continue;
let parsed: unknown;
try {
parsed = JSON.parse(key);
} catch {
continue;
}
if (!Array.isArray(parsed) || parsed.length !== 3 || !parsed.every((segment) => typeof segment === 'string')) continue;
const [keyRuntime, directory, sessionKey] = parsed;
if (keyRuntime !== runtimeKey) continue;
state.removeDraft({ directory, sessionKey }, draftId);
return;
}
});
});
onCommand('addFileMentions', (payload) => {
const rawPaths = Array.isArray((payload as { paths?: unknown[] })?.paths)
? (payload as { paths: unknown[] }).paths