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
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { useInlineCommentDraftStore } from './useInlineCommentDraftStore';
const comment = {
source: 'file' as const,
fileLabel: 'src/app.ts:12',
startLine: 12,
endLine: 12,
code: 'const x = 1',
language: 'typescript',
text: 'fix this',
};
const target = { directory: '/repo', sessionKey: 'session-1' };
const store = () => useInlineCommentDraftStore.getState();
describe('caller-provided draft ids', () => {
afterEach(() => { useInlineCommentDraftStore.setState({ drafts: {}, touchedAt: {} }); });
test('a caller that owns its own view of the draft chooses the id', () => {
// The VS Code editor thread mints the id so it can track its draft without
// waiting for a round trip.
const id = store().addDraft(target, { ...comment, id: 'icd-editor-thread' });
expect(id).toBe('icd-editor-thread');
expect(store().getDrafts(target)[0].id).toBe('icd-editor-thread');
});
test('the chosen id is what removal and lookup accept', () => {
store().addDraft(target, { ...comment, id: 'icd-editor-thread' });
store().removeDraft(target, 'icd-editor-thread');
expect(store().getDrafts(target)).toEqual([]);
});
test('omitting the id still generates one', () => {
const id = store().addDraft(target, comment);
expect(/^icd-\d+-\w+$/.test(id ?? '')).toBe(true);
});
test('a blank id is ignored rather than stored', () => {
const id = store().addDraft(target, { ...comment, id: ' ' });
expect(/^icd-\d+-\w+$/.test(id ?? '')).toBe(true);
});
test('a colliding id is refused, so edits cannot retarget another draft', () => {
const first = store().addDraft(target, { ...comment, id: 'icd-duplicate' });
const second = store().addDraft(target, { ...comment, id: 'icd-duplicate', text: 'different' });
expect(first).toBe('icd-duplicate');
expect(second).not.toBe('icd-duplicate');
const drafts = store().getDrafts(target);
expect(drafts).toHaveLength(2);
expect(new Set(drafts.map((draft) => draft.id)).size).toBe(2);
});
test('the same id may be reused once its draft is gone', () => {
store().addDraft(target, { ...comment, id: 'icd-reused' });
store().removeDraft(target, 'icd-reused');
expect(store().addDraft(target, { ...comment, id: 'icd-reused' })).toBe('icd-reused');
});
test('an id taken in another session does not collide', () => {
const other = { directory: '/repo', sessionKey: 'session-2' };
store().addDraft(target, { ...comment, id: 'icd-shared' });
expect(store().addDraft(other, { ...comment, id: 'icd-shared' })).toBe('icd-shared');
});
});
@@ -36,7 +36,11 @@ interface InlineCommentDraftState {
}
interface InlineCommentDraftActions {
addDraft: (target: InlineCommentDraftTarget, draft: Omit<InlineCommentDraft, 'id' | 'createdAt' | 'sessionKey'>) => string | null;
// Returns the new draft id, or null when the draft is rejected (unresolved
// target, bounds eviction, or an empty terminal-context selection).
// `id` lets an external owner (the VS Code editor comment thread) choose the
// draft id up front. Omitted by every in-app caller, which gets a generated one.
addDraft: (target: InlineCommentDraftTarget, draft: Omit<InlineCommentDraft, 'id' | 'createdAt' | 'sessionKey'> & { id?: string }) => string | null;
updateDraft: (target: InlineCommentDraftTarget, draftId: string, updates: Partial<Omit<InlineCommentDraft, 'id' | 'createdAt' | 'sessionKey'>>) => void;
removeDraft: (target: InlineCommentDraftTarget, draftId: string) => void;
clearDrafts: (target: InlineCommentDraftTarget) => void;
@@ -241,7 +245,15 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
addDraft: (target, draft) => {
const key = getCurrentKey(target);
if (!key || (draft.source === 'terminal' && !draft.code.trim())) return null;
const id = `icd-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
// A caller that owns its own view of the draft (the VS Code editor
// thread) supplies the id so it can correlate without a round trip.
// A colliding id would silently retarget edits and removals at an
// unrelated draft, so it is refused rather than reused.
const requestedId = draft.id?.trim();
const idIsTaken = Boolean(requestedId) && (get().drafts[key] ?? []).some((item) => item.id === requestedId);
const id = requestedId && !idIsTaken
? requestedId
: `icd-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const nextDraft: InlineCommentDraft = { ...draft, sessionKey: target.sessionKey, id, createdAt: Date.now() };
let accepted = false;
set((state) => {