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:
@@ -121,6 +121,14 @@ Why: only navigation tools use the compact static path; all other tools need obs
|
||||
## Quick map of files in this folder
|
||||
|
||||
- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx`
|
||||
- User-attached context (inline code comments, terminal selections, browser
|
||||
annotations, PR comments/checks): `UserContextPart.tsx`. `UserTextPart`
|
||||
routes to it when the part's metadata carries an `openchamberContext`
|
||||
payload (see `lib/messages/contextParts.ts`, which owns both the send-time
|
||||
builder and the read-back parser). Linked GitHub issues/PRs are instead
|
||||
converted to link file-parts in `normalizeUserDisplayParts.ts`. Legacy
|
||||
pre-metadata messages still render via text sniffing (`<terminal_context>`
|
||||
blocks, `GitHub issue context (JSON)` prefixes).
|
||||
- Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
|
||||
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
|
||||
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ContextPartPayload } from '@/lib/messages/contextParts';
|
||||
|
||||
/**
|
||||
* A context item attached to a user message: an inline code comment, a
|
||||
* terminal selection, a browser annotation, or GitHub PR context.
|
||||
*
|
||||
* Each item renders as one card so the user's comment reads as part of the
|
||||
* annotation, not as more message text: a header naming the source (with an
|
||||
* expand affordance when captured code/output exists), and the comment text
|
||||
* below it inside the same card. A header with nothing to reveal renders
|
||||
* without the expand affordance.
|
||||
*/
|
||||
|
||||
const ContextCard: React.FC<{
|
||||
icon: IconName;
|
||||
summary: string;
|
||||
/** Full untruncated context, shown on hover. */
|
||||
title?: string;
|
||||
body: string;
|
||||
text: string;
|
||||
}> = ({ icon, summary, title, body, text }) => {
|
||||
const hasBody = body.trim().length > 0;
|
||||
const hasText = text.trim().length > 0;
|
||||
|
||||
const header = hasBody ? (
|
||||
<details className="min-w-0">
|
||||
<summary className="flex cursor-pointer items-center gap-1.5 px-2.5 py-1.5 text-xs text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] [&::-webkit-details-marker]:hidden" title={title}>
|
||||
<Icon name="arrow-right-s" className="h-3.5 w-3.5 shrink-0 transition-transform [details[open]_&]:rotate-90" />
|
||||
<Icon name={icon} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{summary}</span>
|
||||
</summary>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap border-t border-[var(--interactive-border)] bg-[var(--surface-background)] px-2.5 py-2 font-mono text-xs text-[var(--surface-foreground)]">{body}</pre>
|
||||
</details>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-[var(--surface-mutedForeground)]" title={title}>
|
||||
<Icon name={icon} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{summary}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="my-1 max-w-full overflow-hidden rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]">
|
||||
{header}
|
||||
{hasText ? (
|
||||
<div className="whitespace-pre-wrap break-words border-t border-[var(--interactive-border)] px-2.5 py-2 font-sans text-sm text-[var(--surface-foreground)]">{text}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const basename = (path: string): string => {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1] ?? path;
|
||||
};
|
||||
|
||||
const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
switch (payload.kind) {
|
||||
case 'code-comment': {
|
||||
const file = basename(payload.fileLabel);
|
||||
const summary = payload.startLine === payload.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file, line: payload.startLine })
|
||||
: t('chat.message.context.codeComment', { file, start: payload.startLine, end: payload.endLine });
|
||||
const fullTitle = payload.startLine === payload.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file: payload.fileLabel, line: payload.startLine })
|
||||
: t('chat.message.context.codeComment', { file: payload.fileLabel, start: payload.startLine, end: payload.endLine });
|
||||
return <ContextCard icon="chat-1" summary={summary} title={fullTitle} body={payload.code} text={payload.text} />;
|
||||
}
|
||||
case 'terminal':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="terminal"
|
||||
summary={t('chat.message.terminalContext', {
|
||||
terminal: payload.terminalLabel,
|
||||
start: payload.startLine,
|
||||
end: payload.endLine,
|
||||
})}
|
||||
body={payload.output}
|
||||
text=""
|
||||
/>
|
||||
);
|
||||
case 'browser-annotation':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="global"
|
||||
summary={t('chat.message.context.browserAnnotation', { page: payload.pageUrl })}
|
||||
title={payload.pageUrl}
|
||||
body={payload.prompt}
|
||||
text={payload.text}
|
||||
/>
|
||||
);
|
||||
case 'pr-comment':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="git-pull-request"
|
||||
summary={t('chat.message.context.prComment', { label: payload.label })}
|
||||
body={payload.body}
|
||||
text={payload.text}
|
||||
/>
|
||||
);
|
||||
case 'pr-check':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="close-circle"
|
||||
summary={t('chat.message.context.prCheck', { label: payload.label })}
|
||||
body={payload.output}
|
||||
text={payload.text}
|
||||
/>
|
||||
);
|
||||
case 'chat-quote':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="chat-1"
|
||||
summary={t('chat.message.context.chatQuote')}
|
||||
body={payload.quote}
|
||||
text={payload.text}
|
||||
/>
|
||||
);
|
||||
case 'github-issue':
|
||||
case 'github-pr':
|
||||
// Rendered as link attachments by normalizeUserDisplayParts.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default React.memo(UserContextPart);
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from '@/lib/messages/inlineMessageLinks';
|
||||
import { prepareUserMarkdownContent, SKILL_TOKEN_PATTERN } from './userTextPartContent';
|
||||
import { extractTerminalContexts } from '@/lib/messages/terminalContext';
|
||||
import { readContextPart } from '@/lib/messages/contextParts';
|
||||
import UserContextPart from './UserContextPart';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
@@ -30,6 +32,10 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
||||
};
|
||||
|
||||
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
|
||||
// Structured context (inline comments, terminal selections, annotations,
|
||||
// PR context) renders as a dedicated block instead of raw prompt text.
|
||||
const contextPayload = React.useMemo(() => readContextPart(part), [part]);
|
||||
|
||||
const partWithText = part as PartWithText;
|
||||
const rawText = partWithText.text;
|
||||
const serializedText = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
||||
@@ -224,6 +230,10 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
});
|
||||
}, [agentMention, openSkill, skillByName, textContent]);
|
||||
|
||||
if (contextPayload) {
|
||||
return <UserContextPart payload={contextPayload} />;
|
||||
}
|
||||
|
||||
if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user