feat: collapsible long assistant messages with show more/less toggle

Long assistant messages (>3000 chars) are now collapsed to 400px with a
gradient fade-out and a 'Show more' button. Clicking it reveals the full
message with a 'Show less' button to re-collapse. Streaming messages are
never collapsed. Fully i18n'd (8 languages).
This commit is contained in:
Nicolas Varrot
2026-02-15 06:03:38 +00:00
parent 9bedaba289
commit 578c0d2a47
2 changed files with 78 additions and 7 deletions

View File

@@ -10,7 +10,7 @@ import { CodeBlock } from './CodeBlock';
import { ToolCall } from './ToolCall';
import { ImageBlock } from './ImageBlock';
import { buildImageSrc } from '../lib/image';
import { Bot, User, Wrench, Copy, Check, CheckCheck, RefreshCw, Zap, Info, Webhook, Braces, Clock, AlertCircle, Bookmark } from 'lucide-react';
import { Bot, User, Wrench, Copy, Check, CheckCheck, RefreshCw, Zap, Info, Webhook, Braces, Clock, AlertCircle, Bookmark, ChevronDown } from 'lucide-react';
import { t, getLocale } from '../lib/i18n';
import { useLocale } from '../hooks/useLocale';
import { stripWebhookScaffolding, hasWebhookScaffolding } from '../lib/systemEvent';
@@ -199,6 +199,49 @@ function MarkdownLink(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
const markdownComponents = { pre: CodeBlock, img: MarkdownImage, a: MarkdownLink };
/** Threshold (in characters) above which assistant messages are collapsed by default */
const COLLAPSE_THRESHOLD = 3000;
const COLLAPSED_MAX_HEIGHT = 400; // px
/** Wrapper that collapses long content with a gradient fade and "Show more" button */
function CollapsibleContent({ content, isStreaming, children }: { content: string; isStreaming?: boolean; children: React.ReactNode }) {
const [expanded, setExpanded] = useState(false);
const shouldCollapse = !isStreaming && content.length > COLLAPSE_THRESHOLD;
if (!shouldCollapse || expanded) {
return (
<>
{children}
{shouldCollapse && expanded && (
<button
onClick={() => setExpanded(false)}
className="mt-2 flex items-center gap-1 text-xs text-pc-accent-light hover:text-pc-accent transition-colors"
>
<ChevronDown size={12} className="rotate-180" />
<span>{t('message.showLess')}</span>
</button>
)}
</>
);
}
return (
<div className="relative">
<div style={{ maxHeight: `${COLLAPSED_MAX_HEIGHT}px`, overflow: 'hidden' }}>
{children}
</div>
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-[var(--pc-bg-elevated)] to-transparent pointer-events-none" />
<button
onClick={() => setExpanded(true)}
className="relative mt-1 flex items-center gap-1 text-xs text-pc-accent-light hover:text-pc-accent transition-colors"
>
<ChevronDown size={12} />
<span>{t('message.showMore')}</span>
</button>
</div>
);
}
function renderTextBlocks(blocks: MessageBlock[]) {
return getTextBlocks(blocks).map((block, i) => (
<div key={`text-${i}`} className="markdown-body">
@@ -516,6 +559,8 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
</button>
)}
{/* User-visible text */}
{!isUser ? (
<CollapsibleContent content={message.content || ''} isStreaming={message.isStreaming}>
{message.blocks.length > 0 ? renderTextBlocks(message.blocks) : (
<div className="markdown-body">
<LazyMarkdown components={markdownComponents}>
@@ -523,6 +568,16 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
</LazyMarkdown>
</div>
)}
</CollapsibleContent>
) : (
message.blocks.length > 0 ? renderTextBlocks(message.blocks) : (
<div className="markdown-body">
<LazyMarkdown components={markdownComponents}>
{autoFormatText(message.content)}
</LazyMarkdown>
</div>
)
)}
{/* Inline images */}
{renderImageBlocks(message.blocks)}

View File

@@ -91,6 +91,8 @@ const en = {
'message.copy': 'Copy message',
'message.copied': 'Copied!',
'message.retry': 'Resend message',
'message.showMore': 'Show more',
'message.showLess': 'Show less',
'message.metadata': 'Message details',
'message.rawJson': 'Raw JSON',
'message.hideRawJson': 'Hide raw JSON',
@@ -255,6 +257,8 @@ const fr: Record<keyof typeof en, string> = {
'message.copy': 'Copier le message',
'message.copied': 'Copié !',
'message.retry': 'Renvoyer le message',
'message.showMore': 'Afficher plus',
'message.showLess': 'Afficher moins',
'message.metadata': 'Détails du message',
'message.rawJson': 'JSON brut',
'message.hideRawJson': 'Masquer le JSON brut',
@@ -411,6 +415,8 @@ const es: Record<keyof typeof en, string> = {
'message.copy': 'Copiar mensaje',
'message.copied': '¡Copiado!',
'message.retry': 'Reenviar mensaje',
'message.showMore': 'Ver más',
'message.showLess': 'Ver menos',
'message.metadata': 'Detalles del mensaje',
'message.rawJson': 'JSON sin formato',
'message.hideRawJson': 'Ocultar JSON sin formato',
@@ -569,6 +575,8 @@ const de: Record<keyof typeof en, string> = {
'message.copy': 'Nachricht kopieren',
'message.copied': 'Kopiert!',
'message.retry': 'Nachricht erneut senden',
'message.showMore': 'Mehr anzeigen',
'message.showLess': 'Weniger anzeigen',
'message.metadata': 'Nachrichtendetails',
'message.rawJson': 'Roh-JSON',
'message.hideRawJson': 'Roh-JSON ausblenden',
@@ -725,6 +733,8 @@ const ja: Record<keyof typeof en, string> = {
'message.copy': 'メッセージをコピー',
'message.copied': 'コピーしました!',
'message.retry': 'メッセージを再送信',
'message.showMore': 'もっと見る',
'message.showLess': '折りたたむ',
'message.metadata': 'メッセージの詳細',
'message.rawJson': '生JSON',
'message.hideRawJson': '生JSONを非表示',
@@ -881,6 +891,8 @@ const pt: Record<keyof typeof en, string> = {
'message.copy': 'Copiar mensagem',
'message.copied': 'Copiado!',
'message.retry': 'Reenviar mensagem',
'message.showMore': 'Ver mais',
'message.showLess': 'Ver menos',
'message.metadata': 'Detalhes da mensagem',
'message.rawJson': 'JSON bruto',
'message.hideRawJson': 'Ocultar JSON bruto',
@@ -1037,6 +1049,8 @@ const zh: Record<keyof typeof en, string> = {
'message.copy': '复制消息',
'message.copied': '已复制!',
'message.retry': '重新发送',
'message.showMore': '展开更多',
'message.showLess': '收起',
'message.metadata': '消息详情',
'message.rawJson': '原始 JSON',
'message.hideRawJson': '隐藏原始 JSON',
@@ -1193,6 +1207,8 @@ const it: Record<keyof typeof en, string> = {
'message.copy': 'Copia messaggio',
'message.copied': 'Copiato!',
'message.retry': 'Reinvia messaggio',
'message.showMore': 'Mostra di più',
'message.showLess': 'Mostra meno',
'message.metadata': 'Dettagli messaggio',
'message.rawJson': 'JSON grezzo',
'message.hideRawJson': 'Nascondi JSON grezzo',