feat(chat): messenger-style context cards with message-level collapse
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user