feat(chat): structured context attachments with metadata round-trip
Every user-attached context item (diff/file/plan comments, terminal selections, browser annotations, PR comments and failed checks, linked issues/PRs, and new chat-quote comments from the selection menu) is now sent as its own synthetic text part carrying an openchamberContext metadata payload. The model-facing text keeps the previous wording; the timeline reads the metadata back and renders each item as a context card instead of raw prompt text. Legacy messages still render via the old text sniffing. The selection menu gains a Comment option with an inline multiline input, the quoted fragment stays highlighted while commenting, and on mobile the input overlays the composer pill by rendering inside the composer form. Add to chat is renamed Add to input; the menu is restyled and the mobile Copy tile removed. Terminal drafts move their terminal id out of the language field (persisted-draft migration v3), and the dead preview-console source is deleted.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import {
|
||||
CONTEXT_METADATA_KEY,
|
||||
contextPayloadFromDraft,
|
||||
createContextPart,
|
||||
formatContextText,
|
||||
readContextPart,
|
||||
type ContextPartPayload,
|
||||
} from './contextParts';
|
||||
|
||||
const draft = (overrides: Partial<InlineCommentDraft> = {}): InlineCommentDraft => ({
|
||||
id: 'icd-1',
|
||||
sessionKey: 's1',
|
||||
source: 'diff',
|
||||
fileLabel: 'src/app.ts',
|
||||
startLine: 3,
|
||||
endLine: 5,
|
||||
side: 'modified',
|
||||
code: 'const x = 1;',
|
||||
language: 'ts',
|
||||
text: 'fix this',
|
||||
createdAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('model-facing text', () => {
|
||||
test('diff comments keep the pre-metadata wording, including the side', () => {
|
||||
expect(formatContextText(contextPayloadFromDraft(draft())))
|
||||
.toBe('Comment on `src/app.ts` lines 3-5 (modified):\n```ts\nconst x = 1;\n```\n\nfix this');
|
||||
});
|
||||
|
||||
test('file and plan comments omit the side', () => {
|
||||
expect(formatContextText(contextPayloadFromDraft(draft({ source: 'file', side: undefined }))))
|
||||
.toBe('Comment on `src/app.ts` lines 3-5:\n```ts\nconst x = 1;\n```\n\nfix this');
|
||||
});
|
||||
|
||||
test('terminal selections keep the terminal_context envelope', () => {
|
||||
const payload = contextPayloadFromDraft(draft({
|
||||
source: 'terminal',
|
||||
fileLabel: 'Terminal 1',
|
||||
terminalId: 'term-1',
|
||||
language: '',
|
||||
startLine: 12,
|
||||
endLine: 13,
|
||||
code: 'npm run build\nok',
|
||||
text: '',
|
||||
}));
|
||||
expect(formatContextText(payload)).toBe([
|
||||
'<terminal_context>',
|
||||
'- Terminal 1 lines 12-13:',
|
||||
' 12 | npm run build',
|
||||
' 13 | ok',
|
||||
'</terminal_context>',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('annotations send the prompt, with user text appended when present', () => {
|
||||
const base = draft({ source: 'preview-annotation', fileLabel: 'https://app.dev', code: 'prompt body', text: '' });
|
||||
expect(formatContextText(contextPayloadFromDraft(base))).toBe('prompt body');
|
||||
expect(formatContextText(contextPayloadFromDraft({ ...base, text: 'also this' })))
|
||||
.toBe('prompt body\n\nalso this');
|
||||
});
|
||||
|
||||
test('chat quotes send the fragment as a blockquote with the comment below', () => {
|
||||
expect(formatContextText(contextPayloadFromDraft(draft({ source: 'chat-quote', fileLabel: 'msg_1', code: 'first line\nsecond line', text: 'why so?' }))))
|
||||
.toBe('Comment on this fragment of an earlier message in this conversation:\n> first line\n> second line\n\nwhy so?');
|
||||
});
|
||||
|
||||
test('PR comments and checks keep their attachment wording', () => {
|
||||
expect(formatContextText(contextPayloadFromDraft(draft({ source: 'pr-comment', fileLabel: 'octo/repo#7', code: 'the comment', text: '' }))))
|
||||
.toBe('Attached GitHub PR comment (octo/repo#7):\n\nthe comment');
|
||||
expect(formatContextText(contextPayloadFromDraft(draft({ source: 'pr-check', fileLabel: 'CI / build', code: 'boom', text: 'why?' }))))
|
||||
.toBe('Attached failed GitHub PR check (CI / build):\n```\nboom\n```\n\nwhy?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip through part metadata', () => {
|
||||
const asPart = (payload: ContextPartPayload, text?: string) => ({
|
||||
type: 'text',
|
||||
...createContextPart(payload, text),
|
||||
});
|
||||
|
||||
test('every draft-based kind survives create → read unchanged', () => {
|
||||
const payloads = [
|
||||
contextPayloadFromDraft(draft()),
|
||||
contextPayloadFromDraft(draft({ source: 'plan', side: undefined })),
|
||||
contextPayloadFromDraft(draft({ source: 'terminal', terminalId: 'term-1', language: '' })),
|
||||
contextPayloadFromDraft(draft({ source: 'preview-annotation' })),
|
||||
contextPayloadFromDraft(draft({ source: 'pr-comment' })),
|
||||
contextPayloadFromDraft(draft({ source: 'pr-check' })),
|
||||
contextPayloadFromDraft(draft({ source: 'chat-quote', fileLabel: 'msg_1' })),
|
||||
];
|
||||
for (const payload of payloads) {
|
||||
expect(readContextPart(asPart(payload))).toEqual(payload);
|
||||
}
|
||||
});
|
||||
|
||||
test('github references carry picker-built text and structured identity', () => {
|
||||
const payload: ContextPartPayload = { kind: 'github-issue', number: 3, title: 'Bug', url: 'https://x/issues/3' };
|
||||
const part = asPart(payload, 'GitHub issue context (JSON)\n{}');
|
||||
expect(part.text).toBe('GitHub issue context (JSON)\n{}');
|
||||
expect(readContextPart(part)).toEqual(payload);
|
||||
});
|
||||
|
||||
test('non-text parts, missing metadata, and malformed payloads read as null', () => {
|
||||
expect(readContextPart({ type: 'file', metadata: {} })).toBeNull();
|
||||
expect(readContextPart({ type: 'text' })).toBeNull();
|
||||
expect(readContextPart({ type: 'text', metadata: { [CONTEXT_METADATA_KEY]: { kind: 'nope' } } })).toBeNull();
|
||||
expect(readContextPart({
|
||||
type: 'text',
|
||||
metadata: { [CONTEXT_METADATA_KEY]: { kind: 'terminal', terminalId: 1, terminalLabel: 'x', startLine: 1, endLine: 1, output: '' } },
|
||||
})).toBeNull();
|
||||
expect(readContextPart({
|
||||
type: 'text',
|
||||
metadata: { [CONTEXT_METADATA_KEY]: { kind: 'github-issue', number: 0, title: 't', url: 'u' } },
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Structured context attached to an outgoing message.
|
||||
*
|
||||
* Every user-attached context item — an inline code comment, a terminal
|
||||
* selection, a browser annotation, a GitHub PR comment or failed check, a
|
||||
* linked issue or PR — is sent as its own synthetic text part. The part's
|
||||
* `text` is what the model reads; the part's `metadata[CONTEXT_METADATA_KEY]`
|
||||
* carries the same information structured, so the timeline can render the
|
||||
* context as a dedicated block after the message round-trips through the
|
||||
* OpenCode server (which persists part metadata verbatim).
|
||||
*
|
||||
* This module owns both directions: building the part at send time and
|
||||
* parsing the metadata back at render time. Keeping them together is what
|
||||
* guarantees they cannot drift apart.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { TextPart } from '@opencode-ai/sdk/v2';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { appendTerminalContexts } from './terminalContext';
|
||||
|
||||
export const CONTEXT_METADATA_KEY = 'openchamberContext';
|
||||
|
||||
export type CodeCommentContext = {
|
||||
kind: 'code-comment';
|
||||
source: 'diff' | 'file' | 'plan';
|
||||
fileLabel: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
side?: 'original' | 'modified';
|
||||
language: string;
|
||||
code: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type TerminalContextPayload = {
|
||||
kind: 'terminal';
|
||||
terminalId: string;
|
||||
terminalLabel: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
output: string;
|
||||
};
|
||||
|
||||
type BrowserAnnotationContext = {
|
||||
kind: 'browser-annotation';
|
||||
pageUrl: string;
|
||||
/** The full annotation prompt shown to the model. */
|
||||
prompt: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type PrCommentContext = {
|
||||
kind: 'pr-comment';
|
||||
label: string;
|
||||
body: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type PrCheckContext = {
|
||||
kind: 'pr-check';
|
||||
label: string;
|
||||
output: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type GitHubIssueContext = {
|
||||
kind: 'github-issue';
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ChatQuoteContext = {
|
||||
kind: 'chat-quote';
|
||||
/** The message the quote came from, when known. */
|
||||
messageId?: string;
|
||||
quote: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type GitHubPrContext = {
|
||||
kind: 'github-pr';
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ContextPartPayload =
|
||||
| CodeCommentContext
|
||||
| TerminalContextPayload
|
||||
| BrowserAnnotationContext
|
||||
| PrCommentContext
|
||||
| PrCheckContext
|
||||
| ChatQuoteContext
|
||||
| GitHubIssueContext
|
||||
| GitHubPrContext;
|
||||
|
||||
export type ContextPartMetadata = { [K in typeof CONTEXT_METADATA_KEY]: ContextPartPayload };
|
||||
|
||||
export type ContextPart = {
|
||||
text: string;
|
||||
synthetic: true;
|
||||
metadata: ContextPartMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* The model-facing text for a context payload. The wording intentionally
|
||||
* matches what OpenChamber sent before parts carried metadata, so model
|
||||
* behavior does not change with the transport format.
|
||||
*/
|
||||
export function formatContextText(payload: ContextPartPayload): string {
|
||||
switch (payload.kind) {
|
||||
case 'code-comment': {
|
||||
const range = `lines ${payload.startLine}-${payload.endLine}`;
|
||||
const sideNote = payload.source === 'diff' && payload.side ? ` (${payload.side})` : '';
|
||||
return `Comment on \`${payload.fileLabel}\` ${range}${sideNote}:\n\`\`\`${payload.language}\n${payload.code}\n\`\`\`\n\n${payload.text}`;
|
||||
}
|
||||
case 'terminal':
|
||||
return appendTerminalContexts('', [{
|
||||
terminalId: payload.terminalId,
|
||||
terminalLabel: payload.terminalLabel,
|
||||
startLine: payload.startLine,
|
||||
endLine: payload.endLine,
|
||||
text: payload.output,
|
||||
}]);
|
||||
case 'browser-annotation':
|
||||
return payload.text ? `${payload.prompt}\n\n${payload.text}` : payload.prompt;
|
||||
case 'pr-comment':
|
||||
return `Attached GitHub PR comment (${payload.label}):\n\n${payload.body}${payload.text ? `\n\n${payload.text}` : ''}`;
|
||||
case 'chat-quote': {
|
||||
const quoted = payload.quote.split('\n').map((line) => `> ${line}`).join('\n');
|
||||
return `Comment on this fragment of an earlier message in this conversation:\n${quoted}${payload.text ? `\n\n${payload.text}` : ''}`;
|
||||
}
|
||||
case 'pr-check':
|
||||
return `Attached failed GitHub PR check (${payload.label}):\n\`\`\`\n${payload.output}\n\`\`\`${payload.text ? `\n\n${payload.text}` : ''}`;
|
||||
case 'github-issue':
|
||||
case 'github-pr':
|
||||
// Linked issues/PRs carry server-fetched context text built by
|
||||
// their pickers; there is no default text to derive here.
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the synthetic part for one context payload. `text` overrides the
|
||||
* derived text; github-issue/github-pr payloads require it because their
|
||||
* model-facing context is fetched by the picker, not derived from metadata.
|
||||
*/
|
||||
export function createContextPart(payload: ContextPartPayload, text?: string): ContextPart {
|
||||
const resolvedText = text ?? formatContextText(payload);
|
||||
return {
|
||||
text: resolvedText,
|
||||
synthetic: true,
|
||||
metadata: { [CONTEXT_METADATA_KEY]: payload },
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a composer context draft to its structured payload. */
|
||||
export function contextPayloadFromDraft(draft: InlineCommentDraft): ContextPartPayload {
|
||||
switch (draft.source) {
|
||||
case 'terminal':
|
||||
return {
|
||||
kind: 'terminal',
|
||||
terminalId: draft.terminalId ?? '',
|
||||
terminalLabel: draft.fileLabel,
|
||||
startLine: draft.startLine,
|
||||
endLine: draft.endLine,
|
||||
output: draft.code,
|
||||
};
|
||||
case 'preview-annotation':
|
||||
return {
|
||||
kind: 'browser-annotation',
|
||||
pageUrl: draft.fileLabel,
|
||||
prompt: draft.code,
|
||||
text: draft.text,
|
||||
};
|
||||
case 'pr-comment':
|
||||
return { kind: 'pr-comment', label: draft.fileLabel, body: draft.code, text: draft.text };
|
||||
case 'pr-check':
|
||||
return { kind: 'pr-check', label: draft.fileLabel, output: draft.code, text: draft.text };
|
||||
case 'chat-quote': {
|
||||
const payload: ChatQuoteContext = { kind: 'chat-quote', quote: draft.code, text: draft.text };
|
||||
if (draft.fileLabel) payload.messageId = draft.fileLabel;
|
||||
return payload;
|
||||
}
|
||||
case 'diff':
|
||||
case 'file':
|
||||
case 'plan': {
|
||||
const payload: CodeCommentContext = {
|
||||
kind: 'code-comment',
|
||||
source: draft.source,
|
||||
fileLabel: draft.fileLabel,
|
||||
startLine: draft.startLine,
|
||||
endLine: draft.endLine,
|
||||
language: draft.language,
|
||||
code: draft.code,
|
||||
text: draft.text,
|
||||
};
|
||||
if (draft.source === 'diff' && draft.side) payload.side = draft.side;
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-back: parsing part metadata at the display boundary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const contextPayloadSchema = z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('code-comment'),
|
||||
source: z.enum(['diff', 'file', 'plan']),
|
||||
fileLabel: z.string(),
|
||||
startLine: z.number(),
|
||||
endLine: z.number(),
|
||||
side: z.enum(['original', 'modified']).optional(),
|
||||
language: z.string(),
|
||||
code: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('terminal'),
|
||||
terminalId: z.string(),
|
||||
terminalLabel: z.string(),
|
||||
startLine: z.number(),
|
||||
endLine: z.number(),
|
||||
output: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('browser-annotation'),
|
||||
pageUrl: z.string(),
|
||||
prompt: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('pr-comment'),
|
||||
label: z.string(),
|
||||
body: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('pr-check'),
|
||||
label: z.string(),
|
||||
output: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('chat-quote'),
|
||||
messageId: z.string().optional(),
|
||||
quote: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('github-issue'),
|
||||
number: z.number().int().positive(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('github-pr'),
|
||||
number: z.number().int().positive(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** The subset of a message part that context read-back inspects. */
|
||||
export type ContextCarrierPart = { type: string } & Pick<TextPart, 'metadata'>;
|
||||
|
||||
/**
|
||||
* Read the structured context payload from a message part, if it carries one.
|
||||
* The part comes from the server or an optimistic insert, so the payload is
|
||||
* schema-validated before it is trusted.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { appendTerminalContexts } from './terminalContext';
|
||||
|
||||
/**
|
||||
* Format a single inline comment draft into the standard message format
|
||||
* used by diff, plan, and file viewers
|
||||
*/
|
||||
function formatInlineCommentDraft(draft: InlineCommentDraft): string {
|
||||
const { fileLabel, startLine, endLine, side, language, code, text } = draft;
|
||||
|
||||
// Diff format includes side (original/modified)
|
||||
if (draft.source === 'diff' && side) {
|
||||
return `Comment on \`${fileLabel}\` lines ${startLine}-${endLine} (${side}):\n\`\`\`${language}\n${code}\n\`\`\`\n\n${text}`;
|
||||
}
|
||||
|
||||
if (draft.source === 'preview-console') {
|
||||
return `Attached preview context from \`${fileLabel}\`:\n\`\`\`${language}\n${code}\n\`\`\`\n\n${text}`;
|
||||
}
|
||||
|
||||
if (draft.source === 'preview-annotation') {
|
||||
return text ? `${code}\n\n${text}` : code;
|
||||
}
|
||||
|
||||
if (draft.source === 'pr-comment') {
|
||||
return `Attached GitHub PR comment (${fileLabel}):\n\n${code}${text ? `\n\n${text}` : ''}`;
|
||||
}
|
||||
|
||||
if (draft.source === 'pr-check') {
|
||||
return `Attached failed GitHub PR check (${fileLabel}):\n\`\`\`\n${code}\n\`\`\`${text ? `\n\n${text}` : ''}`;
|
||||
}
|
||||
|
||||
// Plan and file format (no side)
|
||||
return `Comment on \`${fileLabel}\` lines ${startLine}-${endLine}:\n\`\`\`${language}\n${code}\n\`\`\`\n\n${text}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format multiple inline comment drafts into a single string
|
||||
* with each comment separated by a blank line
|
||||
*/
|
||||
function formatInlineCommentDrafts(drafts: InlineCommentDraft[]): string {
|
||||
if (drafts.length === 0) return '';
|
||||
|
||||
if (drafts.every((draft) => draft.source === 'preview-annotation')) {
|
||||
return drafts.map(formatInlineCommentDraft).join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
return drafts.map(formatInlineCommentDraft).join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Append inline comment drafts to an existing message text
|
||||
* If the text is empty, returns just the formatted comments
|
||||
* Otherwise, appends comments after a blank line separator
|
||||
*/
|
||||
export function appendInlineComments(text: string, drafts: InlineCommentDraft[]): string {
|
||||
if (drafts.length === 0) return text;
|
||||
const terminalDrafts = drafts.filter((draft) => draft.source === 'terminal');
|
||||
const otherDrafts = drafts.filter((draft) => draft.source !== 'terminal');
|
||||
const withComments = otherDrafts.length > 0
|
||||
? (text.trim() ? `${text}\n\n${formatInlineCommentDrafts(otherDrafts)}` : formatInlineCommentDrafts(otherDrafts))
|
||||
: text;
|
||||
if (terminalDrafts.length > 0) {
|
||||
return appendTerminalContexts(withComments, terminalDrafts.map((draft) => ({
|
||||
terminalId: draft.language,
|
||||
terminalLabel: draft.fileLabel,
|
||||
startLine: draft.startLine,
|
||||
endLine: draft.endLine,
|
||||
text: draft.code,
|
||||
})));
|
||||
}
|
||||
return withComments;
|
||||
}
|
||||
@@ -120,4 +120,26 @@ describe("filterSyntheticParts", () => {
|
||||
]
|
||||
expect(filterSyntheticParts(parts)).toEqual(parts)
|
||||
})
|
||||
|
||||
test("keeps synthetic parts carrying user context metadata alongside user text", () => {
|
||||
const userPart = createTextPart("1", "user prompt")
|
||||
const contextPart = {
|
||||
...createTextPart("2", "Comment on `x.ts` lines 1-2:\n```ts\ncode\n```\n\nfix", true),
|
||||
metadata: {
|
||||
openchamberContext: {
|
||||
kind: "code-comment",
|
||||
source: "diff",
|
||||
fileLabel: "x.ts",
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
language: "ts",
|
||||
code: "code",
|
||||
text: "fix",
|
||||
},
|
||||
},
|
||||
}
|
||||
const plainSynthetic = createTextPart("3", "instructions", true)
|
||||
expect(filterSyntheticParts([userPart, contextPart, plainSynthetic]))
|
||||
.toEqual([userPart, contextPart])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Part } from "@opencode-ai/sdk/v2";
|
||||
|
||||
import { readContextPart } from "./contextParts";
|
||||
|
||||
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
||||
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
|
||||
|
||||
@@ -39,6 +41,13 @@ export const filterSyntheticParts = (parts: Part[] | undefined): Part[] => {
|
||||
return false;
|
||||
}
|
||||
|
||||
// User-attached context (inline comments, terminal selections, and
|
||||
// such) is synthetic transport-wise but is user content that renders
|
||||
// as its own context block.
|
||||
if (readContextPart(part)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const text = (part as { text?: unknown }).text;
|
||||
if (typeof text !== 'string') {
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user