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:
@@ -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)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user