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 [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
||||||
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
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(() => {
|
const userContentParts = React.useMemo(() => {
|
||||||
return parts.filter((part) => {
|
return parts.filter((part) => {
|
||||||
if (part.type === 'text') {
|
if (part.type === 'text') {
|
||||||
@@ -716,6 +730,16 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
|
|||||||
style={CONTAIN_LAYOUT_STYLE}
|
style={CONTAIN_LAYOUT_STYLE}
|
||||||
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'leading-relaxed text-foreground/90 text-base overflow-x-hidden',
|
'leading-relaxed text-foreground/90 text-base overflow-x-hidden',
|
||||||
@@ -760,6 +784,8 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
|
|||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
agentMention={mentionForPart}
|
agentMention={mentionForPart}
|
||||||
|
messageExpanded={messageExpanded}
|
||||||
|
onExpandMessage={expandMessage}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ import { Icon } from '@/components/icon/Icon';
|
|||||||
import type { IconName } from '@/components/icon/icons';
|
import type { IconName } from '@/components/icon/icons';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import type { ContextPartPayload } from '@/lib/messages/contextParts';
|
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
|
* A context item attached to a user message: an inline code comment, a
|
||||||
* terminal selection, a browser annotation, or GitHub PR context.
|
* 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
|
* The quoted material renders as a messenger-style reply: a source caption and
|
||||||
* annotation, not as more message text: a header naming the source (with an
|
* the quote behind a plain left bar, in muted text, clamped to a few lines
|
||||||
* expand affordance when captured code/output exists), and the comment text
|
* (click toggles the full quote). The user's comment follows below as regular
|
||||||
* below it inside the same card. A header with nothing to reveal renders
|
* message text, so the pair reads as "a reply to this quote" instead of a
|
||||||
* without the expand affordance.
|
* boxed widget inside the bubble.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ContextCard: React.FC<{
|
const ContextCard: React.FC<{
|
||||||
@@ -23,31 +24,69 @@ const ContextCard: React.FC<{
|
|||||||
title?: string;
|
title?: string;
|
||||||
body: string;
|
body: string;
|
||||||
text: 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 hasBody = body.trim().length > 0;
|
||||||
const hasText = text.trim().length > 0;
|
const hasText = text.trim().length > 0;
|
||||||
|
|
||||||
const header = hasBody ? (
|
if (collapsed) {
|
||||||
<details className="min-w-0">
|
// One line per attachment: the source caption, and the user's comment
|
||||||
<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}>
|
// after it when there is one ("Quoted from an earlier message: thanks,
|
||||||
<Icon name="arrow-right-s" className="h-3.5 w-3.5 shrink-0 transition-transform [details[open]_&]:rotate-90" />
|
// 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" />
|
<Icon name={icon} className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="truncate">{summary}</span>
|
<span className="truncate">
|
||||||
</summary>
|
{comment.length > 0 ? `${summary}: ` : 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>
|
{comment.length > 0 ? (
|
||||||
</details>
|
<span className="text-sm text-[var(--surface-foreground)]">{comment}</span>
|
||||||
) : (
|
) : null}
|
||||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-[var(--surface-mutedForeground)]" title={title}>
|
</span>
|
||||||
<Icon name={icon} className="h-3.5 w-3.5 shrink-0" />
|
</div>
|
||||||
<span className="truncate">{summary}</span>
|
);
|
||||||
</div>
|
}
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-1 max-w-full overflow-hidden rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]">
|
<div className="my-1.5 min-w-0 max-w-full">
|
||||||
{header}
|
<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 ? (
|
{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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -58,8 +97,14 @@ const basename = (path: string): string => {
|
|||||||
return segments[segments.length - 1] ?? path;
|
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 { t } = useI18n();
|
||||||
|
const shared = { collapsed, onExpand };
|
||||||
|
|
||||||
switch (payload.kind) {
|
switch (payload.kind) {
|
||||||
case 'code-comment': {
|
case 'code-comment': {
|
||||||
@@ -70,7 +115,7 @@ const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload })
|
|||||||
const fullTitle = payload.startLine === payload.endLine
|
const fullTitle = payload.startLine === payload.endLine
|
||||||
? t('chat.message.context.codeCommentLine', { file: payload.fileLabel, line: payload.startLine })
|
? 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 });
|
: 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':
|
case 'terminal':
|
||||||
return (
|
return (
|
||||||
@@ -83,6 +128,8 @@ const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload })
|
|||||||
})}
|
})}
|
||||||
body={payload.output}
|
body={payload.output}
|
||||||
text=""
|
text=""
|
||||||
|
mono
|
||||||
|
{...shared}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'browser-annotation':
|
case 'browser-annotation':
|
||||||
@@ -93,6 +140,7 @@ const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload })
|
|||||||
title={payload.pageUrl}
|
title={payload.pageUrl}
|
||||||
body={payload.prompt}
|
body={payload.prompt}
|
||||||
text={payload.text}
|
text={payload.text}
|
||||||
|
{...shared}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'pr-comment':
|
case 'pr-comment':
|
||||||
@@ -102,6 +150,7 @@ const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload })
|
|||||||
summary={t('chat.message.context.prComment', { label: payload.label })}
|
summary={t('chat.message.context.prComment', { label: payload.label })}
|
||||||
body={payload.body}
|
body={payload.body}
|
||||||
text={payload.text}
|
text={payload.text}
|
||||||
|
{...shared}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'pr-check':
|
case 'pr-check':
|
||||||
@@ -111,6 +160,8 @@ const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload })
|
|||||||
summary={t('chat.message.context.prCheck', { label: payload.label })}
|
summary={t('chat.message.context.prCheck', { label: payload.label })}
|
||||||
body={payload.output}
|
body={payload.output}
|
||||||
text={payload.text}
|
text={payload.text}
|
||||||
|
mono
|
||||||
|
{...shared}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'file-quote': {
|
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.codeCommentLine', { file, line: payload.startLine })
|
||||||
: t('chat.message.context.codeComment', { file, start: payload.startLine, end: payload.endLine }))
|
: t('chat.message.context.codeComment', { file, start: payload.startLine, end: payload.endLine }))
|
||||||
: t('chat.message.context.fileQuote', { file });
|
: 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':
|
case 'chat-quote':
|
||||||
return (
|
return (
|
||||||
@@ -129,6 +180,7 @@ const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload })
|
|||||||
summary={t('chat.message.context.chatQuote')}
|
summary={t('chat.message.context.chatQuote')}
|
||||||
body={payload.quote}
|
body={payload.quote}
|
||||||
text={payload.text}
|
text={payload.text}
|
||||||
|
{...shared}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'github-issue':
|
case 'github-issue':
|
||||||
|
|||||||
@@ -25,13 +25,22 @@ type UserTextPartProps = {
|
|||||||
messageId: string;
|
messageId: string;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
agentMention?: AgentMentionInfo;
|
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' => {
|
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
|
||||||
return mode === 'markdown' ? '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,
|
// Structured context (inline comments, terminal selections, annotations,
|
||||||
// PR context) renders as a dedicated block instead of raw prompt text.
|
// PR context) renders as a dedicated block instead of raw prompt text.
|
||||||
const contextPayload = React.useMemo(() => readContextPart(part), [part]);
|
const contextPayload = React.useMemo(() => readContextPart(part), [part]);
|
||||||
@@ -51,7 +60,9 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
const effectiveDirectory = useEffectiveDirectory();
|
const effectiveDirectory = useEffectiveDirectory();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode);
|
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 textRef = React.useRef<HTMLDivElement>(null);
|
||||||
const skillByName = React.useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]);
|
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(() => {
|
React.useEffect(() => {
|
||||||
const el = textRef.current;
|
const el = textRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (!collapsibleUserMessages || isExpanded) return;
|
if (!collapsibleUserMessages || effectiveExpanded) return;
|
||||||
|
|
||||||
const checkTruncation = () => {
|
const checkTruncation = () => {
|
||||||
setIsTruncated(el.scrollHeight > el.clientHeight);
|
setIsTruncated(el.scrollHeight > el.clientHeight);
|
||||||
@@ -118,7 +129,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
mutationObserver.disconnect();
|
mutationObserver.disconnect();
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
};
|
};
|
||||||
}, [collapsibleUserMessages, textContent, isExpanded]);
|
}, [collapsibleUserMessages, textContent, effectiveExpanded]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!collapsibleUserMessages) {
|
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
|
// Measure at click time instead of trusting the observed flag: whether
|
||||||
// the text is clipped right now is what decides if expanding does
|
// 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.
|
// 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);
|
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) => {
|
const handleCollapse = React.useCallback((event: React.MouseEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -231,7 +246,13 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
}, [agentMention, openSkill, skillByName, textContent]);
|
}, [agentMention, openSkill, skillByName, textContent]);
|
||||||
|
|
||||||
if (contextPayload) {
|
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) {
|
if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) {
|
||||||
@@ -240,7 +261,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" key={part.id || `${messageId}-user-text`}>
|
<div className="relative" key={part.id || `${messageId}-user-text`}>
|
||||||
{collapsibleUserMessages && isExpanded && (
|
{collapsibleUserMessages && !isControlled && isExpanded && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCollapse}
|
onClick={handleCollapse}
|
||||||
@@ -253,10 +274,10 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"break-words font-sans typography-markdown-body",
|
"break-words font-sans typography-markdown-body",
|
||||||
isExpanded && "pb-3",
|
!isControlled && isExpanded && "pb-3",
|
||||||
normalizedRenderingMode === 'plain' && 'whitespace-pre-wrap',
|
normalizedRenderingMode === 'plain' && 'whitespace-pre-wrap',
|
||||||
isCollapsed && "line-clamp-2",
|
isCollapsed && "line-clamp-2",
|
||||||
collapsibleUserMessages && isTruncated && !isExpanded && "cursor-pointer"
|
collapsibleUserMessages && isTruncated && !effectiveExpanded && "cursor-pointer"
|
||||||
)}
|
)}
|
||||||
ref={textRef}
|
ref={textRef}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
Reference in New Issue
Block a user