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
+33
View File
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { Message, Part, Session } from '@opencode-ai/sdk/v2';
import type { StartBtwInput } from './btw';
let forkSessionImpl: (sessionId: string, messageId?: string, directory?: string | null) => Promise<Session>;
let getSessionMessagesImpl: (id: string, limit?: number, directory?: string | null) => Promise<Array<{ info: Message; parts: Part[] }>>;
@@ -213,6 +214,38 @@ describe('startBtwSession', () => {
expect(sentParts).toEqual([[{ text: BTW_BOUNDARY_INSTRUCTION, synthetic: true }]]);
});
test('the first question keeps inline comment context', async () => {
forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project'));
const commentPart: NonNullable<StartBtwInput['additionalParts']>[number] = {
text: 'Comment on `src/auth.ts` lines 4-4:\n```ts\nauth();\n```\n\ncheck this',
synthetic: true,
metadata: {
openchamberContext: {
kind: 'code-comment',
source: 'file',
fileLabel: 'src/auth.ts',
startLine: 4,
endLine: 4,
language: 'ts',
code: 'auth();',
text: 'check this',
},
},
};
let sentParts: unknown;
sendMessageImpl = (...args) => {
sentParts = args[6];
return Promise.resolve();
};
await startBtwSession({ ...startInput, additionalParts: [commentPart] });
expect(sentParts).toEqual([
{ text: BTW_BOUNDARY_INSTRUCTION, synthetic: true },
commentPart,
]);
});
test('an empty parent produces a marker without a boundary', async () => {
forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project'));
getSessionMessagesImpl = () => Promise.resolve([]);
+11 -2
View File
@@ -6,6 +6,8 @@ import { useBtwStore } from '@/stores/useBtwStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getSyncChildStores, getSyncMessages, registerSessionDirectory } from '@/sync/sync-refs';
import { Binary } from '@/sync/binary';
import type { ContextPartMetadata } from '@/lib/messages/contextParts';
import type { AttachedFile } from '@/stores/types/sessionTypes';
/**
* `/btw <question>`: fork the main session into a temporary session and send
@@ -28,6 +30,13 @@ export type StartBtwInput = {
modelID: string;
agent?: string;
variant?: string;
attachments?: AttachedFile[];
additionalParts?: Array<{
text: string;
attachments?: AttachedFile[];
synthetic?: boolean;
metadata?: ContextPartMetadata;
}>;
};
/**
@@ -196,12 +205,12 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
input.providerID,
input.modelID,
input.agent,
[],
input.attachments ?? [],
undefined,
// The very first question already needs the boundary: the fork is at
// its most dangerous here, with the parent's in-flight plan as the
// newest thing in its context.
btwBoundaryParts(),
[...btwBoundaryParts(), ...(input.additionalParts ?? [])],
input.variant,
'normal',
{ sessionId: forked.id, directory: sessionDirectory },
@@ -121,6 +121,43 @@ describe('round-trip through part metadata', () => {
expect(readContextPart(part)).toEqual(payload);
});
test('code comments also carry OpenCode Desktop metadata', () => {
const payload = contextPayloadFromDraft(draft());
const part = asPart(payload);
expect(part.metadata.opencodeComment).toEqual({
path: 'src/app.ts',
selection: { startLine: 3, endLine: 5, startChar: 0, endChar: 0 },
comment: 'fix this',
preview: 'const x = 1;',
origin: 'review',
});
expect(readContextPart(part)).toEqual(payload);
});
test('reads OpenCode Desktop metadata when canonical metadata is absent', () => {
expect(readContextPart({
type: 'text',
metadata: {
opencodeComment: {
path: 'src/other.ts',
selection: { startLine: 8, endLine: 9 },
comment: 'check this',
preview: 'value',
origin: 'review',
},
},
})).toEqual({
kind: 'code-comment',
source: 'diff',
fileLabel: 'src/other.ts',
startLine: 8,
endLine: 9,
language: '',
code: 'value',
text: 'check this',
});
});
test('non-text parts, missing metadata, and malformed payloads read as null', () => {
expect(readContextPart({ type: 'file', metadata: {} })).toBeNull();
expect(readContextPart({ type: 'text' })).toBeNull();
+77 -4
View File
@@ -20,6 +20,7 @@ import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { appendTerminalContexts } from './terminalContext';
export const CONTEXT_METADATA_KEY = 'openchamberContext';
const OPENCODE_COMMENT_METADATA_KEY = 'opencodeComment';
export type CodeCommentContext = {
kind: 'code-comment';
@@ -115,7 +116,18 @@ export type ContextPartPayload =
| GitHubPrContext
| LinearIssueContext;
export type ContextPartMetadata = { [K in typeof CONTEXT_METADATA_KEY]: ContextPartPayload };
type OpenCodeCommentMetadata = {
path: string;
selection?: { startLine: number; endLine: number; startChar?: number; endChar?: number };
comment: string;
preview?: string;
origin?: 'file' | 'review';
};
export type ContextPartMetadata = {
[CONTEXT_METADATA_KEY]: ContextPartPayload;
[OPENCODE_COMMENT_METADATA_KEY]?: OpenCodeCommentMetadata;
};
export type ContextPart = {
text: string;
@@ -177,10 +189,25 @@ export function formatContextText(payload: ContextPartPayload): string {
*/
export function createContextPart(payload: ContextPartPayload, text?: string): ContextPart {
const resolvedText = text ?? formatContextText(payload);
const metadata: ContextPartMetadata = { [CONTEXT_METADATA_KEY]: payload };
if (payload.kind === 'code-comment') {
metadata[OPENCODE_COMMENT_METADATA_KEY] = {
path: payload.fileLabel,
selection: {
startLine: payload.startLine,
endLine: payload.endLine,
startChar: 0,
endChar: 0,
},
comment: payload.text,
preview: payload.code,
origin: payload.source === 'diff' ? 'review' : 'file',
};
}
return {
text: resolvedText,
synthetic: true,
metadata: { [CONTEXT_METADATA_KEY]: payload },
metadata,
};
}
@@ -319,7 +346,28 @@ const contextPayloadSchema = z.discriminatedUnion('kind', [
* Part metadata carrying a context payload, for parsing at a trust boundary
* (a queued message coming back from the server, for instance).
*/
export const contextPartMetadataSchema = z.object({ [CONTEXT_METADATA_KEY]: contextPayloadSchema });
const openCodeCommentSchema = z.object({
path: z.string(),
selection: z.object({
startLine: z.number().finite(),
endLine: z.number().finite(),
startChar: z.number().finite().optional(),
endChar: z.number().finite().optional(),
}).optional(),
comment: z.string(),
preview: z.string().optional(),
origin: z.enum(['file', 'review']).optional(),
});
/**
* Part metadata carrying a context payload, for parsing at a trust boundary
* (a queued message coming back from the server, for instance). The OpenCode
* Desktop mirror rides along so a queued comment keeps it too.
*/
export const contextPartMetadataSchema = z.object({
[CONTEXT_METADATA_KEY]: contextPayloadSchema,
[OPENCODE_COMMENT_METADATA_KEY]: openCodeCommentSchema.optional(),
});
/** The subset of a message part that context read-back inspects. */
export type ContextCarrierPart = { type: string } & Pick<TextPart, 'metadata'>;
@@ -332,7 +380,32 @@ export type ContextCarrierPart = { type: string } & Pick<TextPart, 'metadata'>;
export function readContextPart(part: ContextCarrierPart): ContextPartPayload | null {
if (part.type !== 'text') return null;
const parsed = contextPayloadSchema.safeParse(part.metadata?.[CONTEXT_METADATA_KEY]);
return parsed.success ? parsed.data : null;
if (parsed.success) return parsed.data;
const compatible = openCodeCommentSchema.safeParse(part.metadata?.[OPENCODE_COMMENT_METADATA_KEY]);
if (compatible.success) {
const comment = compatible.data;
if (!comment.selection) {
return {
kind: 'file-quote',
fileLabel: comment.path,
quote: comment.preview ?? '',
text: comment.comment,
};
}
return {
kind: 'code-comment',
source: comment.origin === 'review' ? 'diff' : 'file',
fileLabel: comment.path,
startLine: comment.selection.startLine,
endLine: comment.selection.endLine,
language: '',
code: comment.preview ?? '',
text: comment.comment,
};
}
return null;
}
/** Whether a message carries any user-attached context part. */