Files
openchamber/packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts
T
Bohdan Triapitsyn 1ae3aeff5f 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.
2026-08-23 23:40:32 +03:00

155 lines
5.7 KiB
TypeScript

import type { Part } from '@opencode-ai/sdk/v2';
import { readContextPart } from '@/lib/messages/contextParts';
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
type GitHubIssueContextPayload = {
issue?: {
number?: unknown;
title?: unknown;
url?: unknown;
};
};
type GitHubPrContextPayload = {
pr?: {
number?: unknown;
title?: unknown;
url?: unknown;
};
};
const isPositiveNumber = (value: unknown): value is number => {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
};
const parseSyntheticJsonPayload = <T>(text: string, prefix: string): T | null => {
const normalizedText = text.trimStart();
if (!normalizedText.startsWith(prefix)) {
return null;
}
const jsonStart = normalizedText.indexOf('{');
if (jsonStart < 0) {
return null;
}
try {
return JSON.parse(normalizedText.slice(jsonStart)) as T;
} catch {
return null;
}
};
const buildGitHubAttachmentPart = (text: string): Part | null => {
const issuePayload = parseSyntheticJsonPayload<GitHubIssueContextPayload>(text, GITHUB_ISSUE_CONTEXT_PREFIX);
if (issuePayload) {
const issue = issuePayload.issue;
const number = issue?.number;
const title = issue?.title;
const url = issue?.url;
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
return null;
}
return {
type: 'file',
mime: 'application/vnd.github.issue-link',
filename: `Issue #${number}: ${title}`,
url,
} as Part;
}
const prPayload = parseSyntheticJsonPayload<GitHubPrContextPayload>(text, GITHUB_PR_CONTEXT_PREFIX);
if (prPayload) {
const pr = prPayload.pr;
const number = pr?.number;
const title = pr?.title;
const url = pr?.url;
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
return null;
}
return {
type: 'file',
mime: 'application/vnd.github.pull-request-link',
filename: `PR #${number}: ${title}`,
url,
} as Part;
}
return null;
};
const shouldKeepSyntheticUserText = (text: string, planModeEnabled: boolean): boolean => {
const trimmed = text.trim();
if (planModeEnabled && trimmed.startsWith('User has requested to enter plan mode')) return true;
if (planModeEnabled && trimmed.startsWith('The plan at ')) return true;
if (trimmed.startsWith('The following tool was executed by the user')) return true;
return false;
};
export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEnabled?: boolean }): Part[] => {
const planModeEnabled = options?.planModeEnabled === true;
return parts
.filter((part) => {
const synthetic = (part as { synthetic?: boolean }).synthetic === true;
if (!synthetic) return true;
if (part.type !== 'text') return false;
if (readContextPart(part)) return true;
const text = (part as { text?: unknown }).text;
if (typeof text !== 'string') {
return false;
}
const normalizedText = text.trimStart();
return shouldKeepSyntheticUserText(text, planModeEnabled)
|| normalizedText.startsWith(GITHUB_ISSUE_CONTEXT_PREFIX)
|| normalizedText.startsWith(GITHUB_PR_CONTEXT_PREFIX);
})
.map((part) => {
const rawPart = part as Record<string, unknown>;
if (rawPart.type === 'compaction') {
return { type: 'text', text: '/compact' } as Part;
}
if (rawPart.type === 'text') {
const text = typeof rawPart.text === 'string' ? rawPart.text.trim() : '';
const synthetic = rawPart.synthetic === true;
if (synthetic) {
const contextPayload = readContextPart(part);
if (contextPayload?.kind === 'github-issue' || contextPayload?.kind === 'github-pr') {
// SAFETY: same display-only file-part shape the legacy
// buildGitHubAttachmentPart produces; consumed by
// FileAttachment, which matches on the mime type.
return {
type: 'file',
mime: contextPayload.kind === 'github-issue'
? 'application/vnd.github.issue-link'
: 'application/vnd.github.pull-request-link',
filename: contextPayload.kind === 'github-issue'
? `Issue #${contextPayload.number}: ${contextPayload.title}`
: `PR #${contextPayload.number}: ${contextPayload.title}`,
url: contextPayload.url,
} as Part;
}
if (contextPayload) {
// Other context kinds render through UserContextPart.
return part;
}
// Legacy messages: sniff the pre-metadata text format.
const attachmentPart = buildGitHubAttachmentPart(text);
if (attachmentPart) {
return attachmentPart;
}
}
if (text.startsWith('The following tool was executed by the user')) {
return { type: 'text', text: '/shell' } as Part;
}
}
return part;
});
};