perf(playground): improve chat markdown rendering

- refine assistant and user message surfaces so chat content matches the app UI.
- normalize markdown typography, tables, images, lists, blockquotes, and details rendering.
- add indentation cues for collapsible reasoning and source sections.
This commit is contained in:
QuentinHsu
2026-06-01 00:19:00 +08:00
parent 4372abd787
commit f53b557a17
7 changed files with 407 additions and 20 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-auto', className)}
className={cn('relative min-h-0 flex-1 overflow-hidden', className)}
initial='smooth'
resize='smooth'
role='log'
+1 -1
View File
@@ -188,7 +188,7 @@ export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
'mt-4 text-sm',
'border-border/70 mt-3 ml-2 border-l pl-4 text-sm',
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 text-muted-foreground data-closed:animate-out data-open:animate-in outline-none',
className
)}
+371 -2
View File
@@ -18,7 +18,15 @@ For commercial licensing, please contact support@quantumnous.com
*/
'use client'
import { type ComponentProps, memo, type ReactNode } from 'react'
import {
Children,
type ComponentProps,
type JSX,
isValidElement,
memo,
type ReactNode,
useState,
} from 'react'
import { Streamdown, type Components } from 'streamdown'
import { cn } from '@/lib/utils'
import {
@@ -33,6 +41,11 @@ type CodeComponentProps = ComponentProps<'code'> & {
'data-block'?: boolean
}
type MarkdownElementProps<T extends keyof JSX.IntrinsicElements> =
ComponentProps<T> & {
node?: unknown
}
function getCodeText(children: ReactNode) {
if (typeof children === 'string') {
return children.replace(/\n$/, '')
@@ -49,7 +62,354 @@ function getCodeLanguage(className?: string) {
return className?.match(/language-([\w#+.-]+)/)?.[1] ?? 'plaintext'
}
function isSummaryElement(child: ReactNode) {
return isValidElement(child) && child.type === 'summary'
}
function MarkdownImage({
alt,
className,
node: _node,
src,
...props
}: MarkdownElementProps<'img'>) {
const [hasError, setHasError] = useState(false)
if (!src || hasError) {
return (
<span className='border-border/70 text-muted-foreground my-4 inline-flex rounded-md border px-3 py-2 text-xs italic'>
{alt || 'Image not available'}
</span>
)
}
return (
<img
alt={alt}
className={cn(
'border-border/70 my-4 block h-auto max-h-96 max-w-full rounded-lg border object-contain',
className
)}
loading='lazy'
onError={() => setHasError(true)}
src={src}
{...props}
/>
)
}
const responseComponents: Components = {
h1({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'h1'>) {
return (
<h1
className={cn(
'mt-6 mb-3 text-xl font-semibold tracking-normal',
className
)}
{...props}
>
{children}
</h1>
)
},
h2({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'h2'>) {
return (
<h2
className={cn(
'mt-6 mb-3 text-lg font-semibold tracking-normal',
className
)}
{...props}
>
{children}
</h2>
)
},
h3({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'h3'>) {
return (
<h3
className={cn(
'mt-5 mb-2 text-base font-semibold tracking-normal',
className
)}
{...props}
>
{children}
</h3>
)
},
h4({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'h4'>) {
return (
<h4
className={cn('mt-5 mb-2 text-sm font-semibold', className)}
{...props}
>
{children}
</h4>
)
},
h5({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'h5'>) {
return (
<h5
className={cn(
'text-muted-foreground mt-4 mb-2 text-sm font-semibold',
className
)}
{...props}
>
{children}
</h5>
)
},
h6({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'h6'>) {
return (
<h6
className={cn(
'text-muted-foreground mt-4 mb-2 text-xs font-semibold uppercase',
className
)}
{...props}
>
{children}
</h6>
)
},
ul({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'ul'>) {
return (
<ul
className={cn(
'my-3 list-outside list-disc space-y-1.5 pl-5',
'[&.contains-task-list]:list-none [&.contains-task-list]:pl-0',
className
)}
{...props}
>
{children}
</ul>
)
},
ol({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'ol'>) {
return (
<ol
className={cn(
'my-3 list-outside list-decimal space-y-1.5 pl-5',
className
)}
{...props}
>
{children}
</ol>
)
},
li({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'li'>) {
return (
<li
className={cn(
'marker:text-muted-foreground pl-1 leading-7',
'[&.task-list-item]:flex [&.task-list-item]:items-start [&.task-list-item]:gap-2 [&.task-list-item]:pl-0',
'[&.task-list-item>input]:accent-primary [&.task-list-item>input]:mt-1.5 [&.task-list-item>input]:size-4',
className
)}
{...props}
>
{children}
</li>
)
},
details({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'details'>) {
const childArray = Children.toArray(children)
const summaryChildren = childArray.filter(isSummaryElement)
const contentChildren = childArray.filter(
(child) => !isSummaryElement(child)
)
return (
<details className={cn('my-4', className)} {...props}>
{summaryChildren}
{contentChildren.length > 0 && (
<div className='border-border/70 ml-5 border-l pl-4'>
{contentChildren}
</div>
)}
</details>
)
},
summary({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'summary'>) {
return (
<summary
className={cn(
'text-foreground marker:text-muted-foreground mb-2 cursor-pointer text-sm font-semibold',
className
)}
{...props}
>
{children}
</summary>
)
},
blockquote({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'blockquote'>) {
return (
<blockquote
className={cn(
'border-border text-muted-foreground my-4 border-l-2 pl-4',
className
)}
{...props}
>
{children}
</blockquote>
)
},
hr({ className, node: _node, ...props }: MarkdownElementProps<'hr'>) {
return <hr className={cn('border-border/70 my-6', className)} {...props} />
},
img: MarkdownImage,
table({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'table'>) {
return (
<div className='border-border/70 my-4 w-full overflow-x-auto rounded-lg border'>
<table
className={cn(
'w-full min-w-max border-separate border-spacing-0 text-sm',
className
)}
{...props}
>
{children}
</table>
</div>
)
},
thead({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'thead'>) {
return (
<thead className={cn('bg-muted/60', className)} {...props}>
{children}
</thead>
)
},
tbody({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'tbody'>) {
return (
<tbody className={cn('divide-border/70 divide-y', className)} {...props}>
{children}
</tbody>
)
},
tr({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'tr'>) {
return (
<tr className={cn('border-border/70', className)} {...props}>
{children}
</tr>
)
},
th({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'th'>) {
return (
<th
className={cn(
'text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap',
className
)}
{...props}
>
{children}
</th>
)
},
td({
children,
className,
node: _node,
...props
}: MarkdownElementProps<'td'>) {
return (
<td className={cn('px-3 py-2 align-top', className)} {...props}>
{children}
</td>
)
},
code({ children, className, ...props }: CodeComponentProps) {
if (!props['data-block']) {
return (
@@ -107,7 +467,16 @@ export const Response = memo(
return (
<Streamdown
className={cn(
'size-full min-w-0 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
'size-full min-w-0 text-pretty',
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
'[&_p]:my-3 [&_p]:leading-7',
'[&_strong]:text-foreground [&_strong]:font-semibold',
'[&_a]:text-primary [&_a]:underline-offset-4 hover:[&_a]:underline',
'[&_details>summary~*]:border-border/70 [&_details]:my-4 [&_details>summary~*]:ml-5 [&_details>summary~*]:border-l [&_details>summary~*]:pl-4',
'[&_summary]:text-foreground [&_summary::marker]:text-muted-foreground [&_summary]:mb-2 [&_summary]:cursor-pointer [&_summary]:text-sm [&_summary]:font-semibold',
'[&_[data-streamdown=table-wrapper]]:border-0 [&_[data-streamdown=table-wrapper]]:bg-transparent [&_[data-streamdown=table-wrapper]]:p-0 [&_[data-streamdown=table-wrapper]]:shadow-none',
'[&_[data-streamdown=table-wrapper]>div:first-child]:hidden',
'[&_[data-streamdown=table-wrapper]>div:last-child]:border-border/70 [&_[data-streamdown=table-wrapper]>div:last-child]:rounded-lg',
className
)}
components={{ ...responseComponents, ...components }}
+1 -1
View File
@@ -73,7 +73,7 @@ export const SourcesContent = ({
}: SourcesContentProps) => (
<CollapsibleContent
className={cn(
'mt-3 flex w-fit flex-col gap-2',
'border-border/70 mt-3 ml-2 flex w-fit flex-col gap-2 border-l pl-4',
'data-closed:fade-out-0 data-closed:slide-out-to-top-2 data-open:slide-in-from-top-2 data-closed:animate-out data-open:animate-in outline-none',
className
)}
@@ -98,11 +98,15 @@ export function PlaygroundChat({
return (
<Message
className='group flex-row-reverse'
className={
message.from === 'assistant'
? 'group flex-row-reverse py-3'
: 'group flex-row-reverse py-1.5'
}
from={message.from}
key={message.key}
>
<div className='w-full min-w-0 flex-1 basis-full py-1'>
<div className='w-full min-w-0 flex-1 basis-full'>
{isEditing ? (
<PlaygroundMessageEditor
editText={editText}
@@ -124,7 +128,7 @@ export function PlaygroundChat({
onDelete={onDeleteMessage}
isGenerating={isGenerating}
alwaysVisible={alwaysShowActions}
className='mt-1'
className='mt-2'
/>
}
message={message}
+2 -2
View File
@@ -73,9 +73,9 @@ export function Playground() {
})
return (
<div className='relative flex size-full flex-col overflow-hidden'>
<div className='relative flex size-full min-h-0 flex-col overflow-hidden'>
{/* Full-width scroll container: scrolling works even over side whitespace */}
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='flex min-h-0 flex-1 flex-col overflow-hidden'>
<PlaygroundChat
messages={messages}
onRegenerateMessage={handleRegenerateMessage}
+24 -10
View File
@@ -22,25 +22,39 @@ For commercial licensing, please contact support@quantumnous.com
*/
export function getMessageContentStyles() {
return [
// Assistant content fills the row; user bubble auto-width
// Assistant content reads like a document column; user bubble stays compact.
'group-[.is-assistant]:w-full',
'group-[.is-assistant]:max-w-none',
'group-[.is-assistant]:max-w-[78ch]',
'group-[.is-user]:w-fit',
// User bubble: rounded and themed background
// User bubble: compact surface that stays calm in both light and dark themes.
'group-[.is-user]:rounded-2xl',
'group-[.is-user]:rounded-br-md',
'group-[.is-user]:border',
'group-[.is-user]:border-border/70',
'group-[.is-user]:bg-muted/70',
'group-[.is-user]:px-4',
'group-[.is-user]:py-2.5',
'group-[.is-user]:text-foreground',
'group-[.is-user]:bg-secondary',
'dark:group-[.is-user]:bg-muted',
'group-[.is-user]:rounded-3xl',
// Assistant bubble: flat serif style (one-sided style)
'group-[.is-assistant]:text-foreground',
'group-[.is-user]:shadow-sm',
'group-[.is-user]:shadow-black/5',
// Assistant response: flat reading surface using the active UI font axis.
'group-[.is-assistant]:bg-transparent',
'group-[.is-assistant]:p-0',
'group-[.is-assistant]:font-serif',
'group-[.is-assistant]:rounded-none',
'group-[.is-assistant]:overflow-visible',
'group-[.is-assistant]:[font-family:var(--font-body)]',
'group-[.is-assistant]:text-foreground/90',
// Preferred readable widths and wrapping
'leading-relaxed',
'text-[0.95rem]',
'leading-6',
'break-words',
'whitespace-pre-wrap',
'sm:text-[0.975rem]',
'sm:leading-7',
// Cap user bubble width so it does not look like a banner
'group-[.is-user]:max-w-[85%]',
'sm:group-[.is-user]:max-w-[62ch]',