diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 9def5286..3623d30e 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -489,6 +489,20 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi const [copyHintVisible, setCopyHintVisible] = React.useState(false); const copyHintTimeoutRef = React.useRef(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 && ( + + )}
); diff --git a/packages/ui/src/components/chat/message/parts/UserContextPart.tsx b/packages/ui/src/components/chat/message/parts/UserContextPart.tsx index 3f1e2cac..4402b243 100644 --- a/packages/ui/src/components/chat/message/parts/UserContextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/UserContextPart.tsx @@ -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 ? ( -
- - + 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 ( +
- {summary} -
-
{body}
-
- ) : ( -
- - {summary} -
- ); + + {comment.length > 0 ? `${summary}: ` : summary} + {comment.length > 0 ? ( + {comment} + ) : null} + +
+ ); + } return ( -
- {header} +
+
setExpanded((value) => !value) : undefined} + title={title} + > +
+ + {summary} +
+ {hasBody ? ( +
+ {body} +
+ ) : null} +
{hasText ? ( -
{text}
+
{text}
) : null}
); @@ -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 ; + return ; } 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 ; + return ; } 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': diff --git a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx index 39058b67..8acd64aa 100644 --- a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx @@ -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 = ({ part, messageId, agentMention }) => { +const UserTextPart: React.FC = ({ 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 = ({ 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(null); const skillByName = React.useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]); @@ -78,7 +89,7 @@ const UserTextPart: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ part, messageId, agentMenti }, [agentMention, openSkill, skillByName, textContent]); if (contextPayload) { - return ; + return ( + setIsExpanded(true)} + /> + ); } if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) { @@ -240,7 +261,7 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti return (
- {collapsibleUserMessages && isExpanded && ( + {collapsibleUserMessages && !isControlled && isExpanded && (