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
+20 -22
View File
@@ -21,7 +21,6 @@ import { buildLinkedIssue } from '@/lib/linkedIssues';
import { useUserMessageHistory } from "@/sync/sync-context";
import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { appendInlineComments } from '@/lib/messages/inlineComments';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { startReviewFlow } from '@/lib/reviewFlow';
import { getRuntimeKey } from '@/lib/runtime-switch';
@@ -757,21 +756,21 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
React.useCallback(
(state) => {
const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : [];
let previewConsole = 0;
let previewAnnotation = 0;
let review = 0;
let terminal = 0;
let prComment = 0;
let prCheck = 0;
let chatQuote = 0;
for (const draft of drafts) {
if (draft.source === 'preview-console') previewConsole += 1;
else if (draft.source === 'preview-annotation') previewAnnotation += 1;
if (draft.source === 'preview-annotation') previewAnnotation += 1;
else if (draft.source === 'terminal') terminal += 1;
else if (draft.source === 'pr-comment') prComment += 1;
else if (draft.source === 'pr-check') prCheck += 1;
else if (draft.source === 'chat-quote') chatQuote += 1;
else review += 1;
}
return `${previewConsole}:${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}`;
return `${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}:${chatQuote}`;
},
[inlineDraftKey]
)
@@ -779,11 +778,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft);
const hasDrafts = draftCount > 0;
const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
const [previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount, chatQuoteCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
const terminalContextDrafts = terminalContextCount > 0
? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal')
: [];
const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => {
const removePreviewDrafts = React.useCallback((source: 'preview-annotation' | 'pr-comment' | 'pr-check' | 'chat-quote') => {
if (!inlineDraftTarget) return;
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
for (const draft of drafts) {
@@ -797,7 +796,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
if (!inlineDraftTarget) return;
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
for (const draft of drafts) {
if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check') {
if (draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check' && draft.source !== 'chat-quote') {
removeInlineCommentDraft(inlineDraftTarget, draft.id);
}
}
@@ -930,12 +929,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const inputSnapshot = getCurrentInputSnapshot();
if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return;
const drafts = inlineDraftTarget ? consumeDrafts(inlineDraftTarget) : [];
let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
if (drafts.length > 0) {
messageToQueue = appendInlineComments(messageToQueue, drafts);
}
// Context drafts stay in their store: the send that later delivers the
// queue consumes them and attaches them as structured context parts.
const messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles);
addToQueue(messageQueueTarget, {
@@ -961,7 +957,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
if (!isMobile) {
composerRef.current?.focus();
}
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant]);
const handleQueuedMessageEdit = React.useCallback((content: string) => {
setMessage(content);
@@ -1140,9 +1136,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
}
// Inline review comments and synthetic context are consumed before
// assembly so a failed send can restore exactly what it took.
// assembly so a failed send can restore exactly what it took. Context
// drafts ride with whichever send goes out next, including queued
// auto-sends: queueing leaves them in the store on purpose.
const syntheticParts = consumePendingSyntheticParts();
const consumedDraftTarget = queuedOnly ? null : inlineDraftTarget;
const consumedDraftTarget = inlineDraftTarget;
const drafts: InlineCommentDraft[] = consumedDraftTarget
? consumeDrafts(consumedDraftTarget)
: [];
@@ -1157,9 +1155,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
composerAttachments: attachedFiles,
inlineComments: drafts,
syntheticTexts: syntheticParts?.map((part) => part.text) ?? [],
linkedIssueContext: linkedIssue?.contextText ?? null,
linkedIssue: linkedIssue
? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText }
: null,
linkedPr: linkedPr
? { instructions: linkedPr.instructionsText, context: linkedPr.contextText }
? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText }
: null,
}, {
parseAgentMention: (text) => {
@@ -1172,8 +1172,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
},
sanitizeAttachments: sanitizeAttachmentsForSend,
collectSkillNames: (text) => collectInlineSkillMentions(text, availableSkillNames),
appendComments: (text, comments) =>
appendInlineComments(text, comments as InlineCommentDraft[]),
buildSkillInstruction: buildSkillMentionInstruction,
});
@@ -2667,8 +2665,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
reviewCount={reviewCount}
prCommentCount={prCommentCount}
prCheckCount={prCheckCount}
previewConsoleCount={previewConsoleCount}
previewAnnotationCount={previewAnnotationCount}
chatQuoteCount={chatQuoteCount}
draftTarget={inlineDraftTarget}
onRemoveDraft={removeInlineCommentDraft}
onRemoveReviewDrafts={removeReviewDrafts}