feat(chat): messenger-style context cards with message-level collapse

This commit is contained in:
Bohdan Triapitsyn
2026-08-24 12:46:14 +03:00
parent cd2a3efcdf
commit 83ea72de0e
3 changed files with 136 additions and 37 deletions
@@ -489,6 +489,20 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
const copyHintTimeoutRef = React.useRef<number | null>(null);
// One expanded state for the whole message: text parts and context cards
// collapse and expand together, with a single collapse control up here
// instead of one per part.
const collapsibleUserMessages = useUIStore((state) => state.collapsibleUserMessages);
const [messageExpanded, setMessageExpanded] = React.useState(false);
const expandMessage = React.useCallback(() => setMessageExpanded(true), []);
const collapseMessage = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
setMessageExpanded(false);
}, []);
React.useEffect(() => {
if (!collapsibleUserMessages) setMessageExpanded(false);
}, [collapsibleUserMessages]);
const userContentParts = React.useMemo(() => {
return parts.filter((part) => {
if (part.type === 'text') {
@@ -716,6 +730,16 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
style={CONTAIN_LAYOUT_STYLE}
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
>
{collapsibleUserMessages && messageExpanded && (
<button
type="button"
onClick={collapseMessage}
className="absolute top-0 right-0 z-10 flex items-center justify-center rounded-sm bg-[var(--surface-elevated)] p-0.5 text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
aria-label={t('chat.message.userText.collapseAria')}
>
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
'leading-relaxed text-foreground/90 text-base overflow-x-hidden',
@@ -760,6 +784,8 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
messageId={messageId}
isMobile={isMobile}
agentMention={mentionForPart}
messageExpanded={messageExpanded}
onExpandMessage={expandMessage}
/>
</React.Fragment>
);
@@ -4,16 +4,17 @@ 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';
import { cn } from '@/lib/utils';
/**
* 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.
* The quoted material renders as a messenger-style reply: a source caption and
* the quote behind a plain left bar, in muted text, clamped to a few lines
* (click toggles the full quote). The user's comment follows below as regular
* message text, so the pair reads as "a reply to this quote" instead of a
* boxed widget inside the bubble.
*/
const ContextCard: React.FC<{
@@ -23,31 +24,69 @@ const ContextCard: React.FC<{
title?: string;
body: string;
text: string;
}> = ({ icon, summary, title, body, text }) => {
/** Render the quote in the code font (code, terminal output, CI logs). */
mono?: boolean;
/**
* Message-level collapse: with collapsible messages on, the whole user
* message (text parts and cards alike) shares one expanded state, so a
* collapsed card is a two-line preview and a click asks the message to
* expand instead of toggling anything of its own.
*/
collapsed?: boolean;
onExpand?: () => void;
}> = ({ icon, summary, title, body, text, mono, collapsed, onExpand }) => {
const [expanded, setExpanded] = React.useState(false);
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" />
if (collapsed) {
// One line per attachment: the source caption, and the user's comment
// after it when there is one ("Quoted from an earlier message: thanks,
// that settles it"). Attachments without a comment (terminal output
// and the like) collapse to the caption alone.
const comment = text.trim();
return (
<div
className="my-1 flex min-w-0 max-w-full cursor-pointer items-center gap-1.5 border-l-2 border-[var(--interactive-border)] pl-3 text-xs text-[var(--surface-mutedForeground)]"
onClick={onExpand}
title={title}
>
<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>
);
<span className="truncate">
{comment.length > 0 ? `${summary}: ` : summary}
{comment.length > 0 ? (
<span className="text-sm text-[var(--surface-foreground)]">{comment}</span>
) : null}
</span>
</div>
);
}
return (
<div className="my-1 max-w-full overflow-hidden rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]">
{header}
<div className="my-1.5 min-w-0 max-w-full">
<div
className={cn('min-w-0 border-l-2 border-[var(--interactive-border)] pl-3', hasBody && 'cursor-pointer')}
onClick={hasBody ? () => setExpanded((value) => !value) : undefined}
title={title}
>
<div className="flex items-center gap-1.5 text-xs text-[var(--surface-mutedForeground)]">
<Icon name={icon} className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{summary}</span>
</div>
{hasBody ? (
<div
className={cn(
'mt-1 whitespace-pre-wrap break-words text-[var(--surface-mutedForeground)]',
mono ? 'font-mono text-xs leading-5' : 'text-sm',
!expanded && 'line-clamp-4'
)}
>
{body}
</div>
) : null}
</div>
{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>
<div className="mt-1.5 whitespace-pre-wrap break-words font-sans text-sm text-[var(--surface-foreground)]">{text}</div>
) : null}
</div>
);
@@ -58,8 +97,14 @@ const basename = (path: string): string => {
return segments[segments.length - 1] ?? path;
};
const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload }) => {
const UserContextPart: React.FC<{
payload: ContextPartPayload;
/** Message-level collapse state, shared with the text parts. */
collapsed?: boolean;
onExpand?: () => void;
}> = ({ payload, collapsed, onExpand }) => {
const { t } = useI18n();
const shared = { collapsed, onExpand };
switch (payload.kind) {
case 'code-comment': {
@@ -70,7 +115,7 @@ const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload })
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} />;
return <ContextCard icon="chat-1" summary={summary} title={fullTitle} body={payload.code} text={payload.text} mono {...shared} />;
}
case 'terminal':
return (
@@ -83,6 +128,8 @@ const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload })
})}
body={payload.output}
text=""
mono
{...shared}
/>
);
case 'browser-annotation':
@@ -93,6 +140,7 @@ const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload })
title={payload.pageUrl}
body={payload.prompt}
text={payload.text}
{...shared}
/>
);
case 'pr-comment':
@@ -102,6 +150,7 @@ const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload })
summary={t('chat.message.context.prComment', { label: payload.label })}
body={payload.body}
text={payload.text}
{...shared}
/>
);
case 'pr-check':
@@ -111,6 +160,8 @@ const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload })
summary={t('chat.message.context.prCheck', { label: payload.label })}
body={payload.output}
text={payload.text}
mono
{...shared}
/>
);
case 'file-quote': {
@@ -120,7 +171,7 @@ const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload })
? t('chat.message.context.codeCommentLine', { file, line: payload.startLine })
: t('chat.message.context.codeComment', { file, start: payload.startLine, end: payload.endLine }))
: t('chat.message.context.fileQuote', { file });
return <ContextCard icon="chat-1" summary={summary} title={payload.fileLabel} body={payload.quote} text={payload.text} />;
return <ContextCard icon="chat-1" summary={summary} title={payload.fileLabel} body={payload.quote} text={payload.text} {...shared} />;
}
case 'chat-quote':
return (
@@ -129,6 +180,7 @@ const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload })
summary={t('chat.message.context.chatQuote')}
body={payload.quote}
text={payload.text}
{...shared}
/>
);
case 'github-issue':
@@ -25,13 +25,22 @@ type UserTextPartProps = {
messageId: string;
isMobile: boolean;
agentMention?: AgentMentionInfo;
/**
* Message-level collapse: when provided, all parts of the user message
* share one expanded state owned by the message body, expanding any part
* expands the whole message, and the message body renders the single
* collapse control. When absent the part collapses on its own (legacy
* single-part behavior).
*/
messageExpanded?: boolean;
onExpandMessage?: () => void;
};
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
return mode === 'markdown' ? 'markdown' : 'plain';
};
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention, messageExpanded, onExpandMessage }) => {
// 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]);
@@ -51,7 +60,9 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
const effectiveDirectory = useEffectiveDirectory();
const { t } = useI18n();
const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode);
const isCollapsed = collapsibleUserMessages && !isExpanded;
const isControlled = messageExpanded !== undefined;
const effectiveExpanded = messageExpanded ?? isExpanded;
const isCollapsed = collapsibleUserMessages && !effectiveExpanded;
const textRef = React.useRef<HTMLDivElement>(null);
const skillByName = React.useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]);
@@ -78,7 +89,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
React.useEffect(() => {
const el = textRef.current;
if (!el) return;
if (!collapsibleUserMessages || isExpanded) return;
if (!collapsibleUserMessages || effectiveExpanded) return;
const checkTruncation = () => {
setIsTruncated(el.scrollHeight > el.clientHeight);
@@ -118,7 +129,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
mutationObserver.disconnect();
resizeObserver.disconnect();
};
}, [collapsibleUserMessages, textContent, isExpanded]);
}, [collapsibleUserMessages, textContent, effectiveExpanded]);
React.useEffect(() => {
if (!collapsibleUserMessages) {
@@ -151,11 +162,15 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
// Measure at click time instead of trusting the observed flag: whether
// the text is clipped right now is what decides if expanding does
// anything, and the flag can still be catching up on a fresh message.
if (collapsibleUserMessages && !isExpanded && element.scrollHeight > element.clientHeight) {
if (collapsibleUserMessages && !effectiveExpanded && element.scrollHeight > element.clientHeight) {
setIsTruncated(true);
setIsExpanded(true);
if (isControlled) {
onExpandMessage?.();
} else {
setIsExpanded(true);
}
}
}, [collapsibleUserMessages, hasActiveSelectionInElement, isExpanded, openSkill]);
}, [collapsibleUserMessages, effectiveExpanded, hasActiveSelectionInElement, isControlled, onExpandMessage, openSkill]);
const handleCollapse = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
@@ -231,7 +246,13 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
}, [agentMention, openSkill, skillByName, textContent]);
if (contextPayload) {
return <UserContextPart payload={contextPayload} />;
return (
<UserContextPart
payload={contextPayload}
collapsed={isCollapsed}
onExpand={isControlled ? onExpandMessage : () => setIsExpanded(true)}
/>
);
}
if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) {
@@ -240,7 +261,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
return (
<div className="relative" key={part.id || `${messageId}-user-text`}>
{collapsibleUserMessages && isExpanded && (
{collapsibleUserMessages && !isControlled && isExpanded && (
<button
type="button"
onClick={handleCollapse}
@@ -253,10 +274,10 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
<div
className={cn(
"break-words font-sans typography-markdown-body",
isExpanded && "pb-3",
!isControlled && isExpanded && "pb-3",
normalizedRenderingMode === 'plain' && 'whitespace-pre-wrap',
isCollapsed && "line-clamp-2",
collapsibleUserMessages && isTruncated && !isExpanded && "cursor-pointer"
collapsibleUserMessages && isTruncated && !effectiveExpanded && "cursor-pointer"
)}
ref={textRef}
onClick={handleClick}