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:
Bohdan Triapitsyn
2026-08-23 23:40:32 +03:00
parent 1af8b14bbb
commit 1ae3aeff5f
37 changed files with 1440 additions and 318 deletions
@@ -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;
}