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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
d323b51a0a
commit
a12b9be443
@@ -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([]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user