fix(chat): align code block actions and restore horizontal scrolling across desktop and mobile (#564)
* fix(chat): use streamdown code controls for markdown blocks * fix(chat): keep code block actions in header row and body scrollable * fix(chat): allow horizontal scrolling in virtualized code output * fix(chat): preserve mobile horizontal scrolling in code blocks * fix: refine chat code block header and actions styling * fix: restore themed code highlighting and polish chat code blocks --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
655b5ba823
commit
73e533a315
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Streamdown } from 'streamdown';
|
import { Streamdown } from 'streamdown';
|
||||||
import { code } from '@streamdown/code';
|
import { createCodePlugin } from '@streamdown/code';
|
||||||
import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
|
import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
|
||||||
import 'streamdown/styles.css';
|
import 'streamdown/styles.css';
|
||||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||||
@@ -124,6 +124,20 @@ const useMarkdownShikiThemes = (): readonly [string | object, string | object] =
|
|||||||
return isVSCode ? themes : fallbackThemes;
|
return isVSCode ? themes : fallbackThemes;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StreamdownCodeThemes = NonNullable<Parameters<typeof createCodePlugin>[0]>['themes'];
|
||||||
|
|
||||||
|
const useStreamdownPlugins = (shikiThemes: readonly [string | object, string | object]) => {
|
||||||
|
return React.useMemo(
|
||||||
|
() => ({
|
||||||
|
code: createCodePlugin({
|
||||||
|
// Streamdown code plugin runtime accepts theme objects, but current type only models bundled theme names.
|
||||||
|
themes: shikiThemes as unknown as StreamdownCodeThemes,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
[shikiThemes],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const useCurrentMermaidTheme = () => {
|
const useCurrentMermaidTheme = () => {
|
||||||
const themeSystem = useOptionalThemeSystem();
|
const themeSystem = useOptionalThemeSystem();
|
||||||
const fallbackLight = getDefaultTheme(false);
|
const fallbackLight = getDefaultTheme(false);
|
||||||
@@ -543,9 +557,6 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className, style, ...props }) => {
|
const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className, style, ...props }) => {
|
||||||
const [copied, setCopied] = React.useState(false);
|
|
||||||
const codeRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
const { isMobile } = useDeviceInfo();
|
|
||||||
const mermaidInfo = getMermaidInfo(children);
|
const mermaidInfo = getMermaidInfo(children);
|
||||||
const mermaidRenderingMode = useUIStore((state) => state.mermaidRenderingMode);
|
const mermaidRenderingMode = useUIStore((state) => state.mermaidRenderingMode);
|
||||||
const codeChild = React.useMemo(
|
const codeChild = React.useMemo(
|
||||||
@@ -583,7 +594,6 @@ const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className
|
|||||||
const bg = normalizeDeclarationString((style as React.CSSProperties).backgroundColor);
|
const bg = normalizeDeclarationString((style as React.CSSProperties).backgroundColor);
|
||||||
if (bg.value) {
|
if (bg.value) {
|
||||||
next.backgroundColor = bg.value;
|
next.backgroundColor = bg.value;
|
||||||
(next as Record<string, string>)['--shiki-light-bg'] = bg.value;
|
|
||||||
}
|
}
|
||||||
for (const [k, v] of Object.entries(bg.vars)) {
|
for (const [k, v] of Object.entries(bg.vars)) {
|
||||||
(next as Record<string, string>)[k] = v;
|
(next as Record<string, string>)[k] = v;
|
||||||
@@ -592,7 +602,6 @@ const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className
|
|||||||
const fg = normalizeDeclarationString((style as React.CSSProperties).color);
|
const fg = normalizeDeclarationString((style as React.CSSProperties).color);
|
||||||
if (fg.value) {
|
if (fg.value) {
|
||||||
next.color = fg.value;
|
next.color = fg.value;
|
||||||
(next as Record<string, string>)['--shiki-light'] = fg.value;
|
|
||||||
}
|
}
|
||||||
for (const [k, v] of Object.entries(fg.vars)) {
|
for (const [k, v] of Object.entries(fg.vars)) {
|
||||||
(next as Record<string, string>)[k] = v;
|
(next as Record<string, string>)[k] = v;
|
||||||
@@ -605,49 +614,14 @@ const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className
|
|||||||
return <MermaidBlock source={mermaidInfo.source} mode={mermaidRenderingMode} />;
|
return <MermaidBlock source={mermaidInfo.source} mode={mermaidRenderingMode} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCodeContent = (): string => {
|
|
||||||
if (!codeRef.current) return '';
|
|
||||||
const codeEl = codeRef.current.querySelector('code');
|
|
||||||
|
|
||||||
return codeEl?.innerText || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
const code = getCodeContent();
|
|
||||||
if (!code) return;
|
|
||||||
const result = await copyTextToClipboard(code);
|
|
||||||
if (result.ok) {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} else {
|
|
||||||
console.error('Failed to copy:', result.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative" ref={codeRef}>
|
<pre
|
||||||
<pre
|
{...props}
|
||||||
{...props}
|
className={cn(className, 'w-full min-w-full')}
|
||||||
className={cn(className)}
|
style={normalizedStyle}
|
||||||
style={normalizedStyle}
|
>
|
||||||
>
|
{codeChild}
|
||||||
{codeChild}
|
</pre>
|
||||||
</pre>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'absolute top-1 right-2 transition-opacity',
|
|
||||||
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
title="Copy"
|
|
||||||
>
|
|
||||||
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -656,12 +630,8 @@ const streamdownComponents = {
|
|||||||
table: TableWrapper,
|
table: TableWrapper,
|
||||||
};
|
};
|
||||||
|
|
||||||
const streamdownPlugins = {
|
|
||||||
code,
|
|
||||||
};
|
|
||||||
|
|
||||||
const streamdownControls = {
|
const streamdownControls = {
|
||||||
code: false,
|
code: true,
|
||||||
table: false,
|
table: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -821,6 +791,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
useMermaidInlineInteractions({ containerRef: streamdownContainerRef, mermaidBlocks, onShowPopup });
|
useMermaidInlineInteractions({ containerRef: streamdownContainerRef, mermaidBlocks, onShowPopup });
|
||||||
|
|
||||||
const shikiThemes = useMarkdownShikiThemes();
|
const shikiThemes = useMarkdownShikiThemes();
|
||||||
|
const streamdownPlugins = useStreamdownPlugins(shikiThemes);
|
||||||
const currentMermaidTheme = useCurrentMermaidTheme();
|
const currentMermaidTheme = useCurrentMermaidTheme();
|
||||||
const componentKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
const componentKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||||
|
|
||||||
@@ -829,7 +800,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
: 'streamdown-content';
|
: 'streamdown-content';
|
||||||
|
|
||||||
const markdownContent = (
|
const markdownContent = (
|
||||||
<div className={cn('break-words', className)} ref={streamdownContainerRef}>
|
<div className={cn('break-words w-full min-w-0', className)} ref={streamdownContainerRef}>
|
||||||
<Streamdown
|
<Streamdown
|
||||||
key={`streamdown-${componentKey}-${currentMermaidTheme.metadata.id}:${currentMermaidTheme.metadata.variant}`}
|
key={`streamdown-${componentKey}-${currentMermaidTheme.metadata.id}:${currentMermaidTheme.metadata.variant}`}
|
||||||
mode={isStreaming ? 'streaming' : 'static'}
|
mode={isStreaming ? 'streaming' : 'static'}
|
||||||
@@ -838,7 +809,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
controls={streamdownControls}
|
controls={streamdownControls}
|
||||||
plugins={streamdownPlugins}
|
plugins={streamdownPlugins}
|
||||||
components={streamdownComponents}
|
components={streamdownComponents}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</Streamdown>
|
</Streamdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -887,6 +858,7 @@ export const SimpleMarkdownRenderer: React.FC<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
const shikiThemes = useMarkdownShikiThemes();
|
const shikiThemes = useMarkdownShikiThemes();
|
||||||
|
const streamdownPlugins = useStreamdownPlugins(shikiThemes);
|
||||||
const currentMermaidTheme = useCurrentMermaidTheme();
|
const currentMermaidTheme = useCurrentMermaidTheme();
|
||||||
|
|
||||||
const streamdownClassName = variant === 'tool'
|
const streamdownClassName = variant === 'tool'
|
||||||
@@ -894,7 +866,7 @@ export const SimpleMarkdownRenderer: React.FC<{
|
|||||||
: 'streamdown-content';
|
: 'streamdown-content';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('break-words', className)} ref={streamdownContainerRef}>
|
<div className={cn('break-words w-full min-w-0', className)} ref={streamdownContainerRef}>
|
||||||
<Streamdown
|
<Streamdown
|
||||||
key={`streamdown-simple-${currentMermaidTheme.metadata.id}:${currentMermaidTheme.metadata.variant}`}
|
key={`streamdown-simple-${currentMermaidTheme.metadata.id}:${currentMermaidTheme.metadata.variant}`}
|
||||||
mode="static"
|
mode="static"
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.m
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism"
|
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism"
|
||||||
style={{ maxHeight, overflowY: 'auto' }}
|
style={{ maxHeight, overflow: 'auto' }}
|
||||||
>
|
>
|
||||||
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
|
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
|
||||||
{lines.map((line, idx) => (
|
{lines.map((line, idx) => (
|
||||||
@@ -256,7 +256,7 @@ const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
|
|||||||
<div
|
<div
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism"
|
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism"
|
||||||
style={{ maxHeight, overflowY: 'auto' }}
|
style={{ maxHeight, overflow: 'auto' }}
|
||||||
>
|
>
|
||||||
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
|
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
|
||||||
<div
|
<div
|
||||||
@@ -327,8 +327,7 @@ const Row: React.FC<RowProps> = React.memo(({ line, language, showLineNumbers, s
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="whitespace-pre-wrap break-all"
|
className="whitespace-pre"
|
||||||
style={{ overflowWrap: 'anywhere' }}
|
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+135
-3
@@ -825,12 +825,28 @@ html:not(.dark) .chat-scroll {
|
|||||||
/* Streamdown code blocks: ensure light/dark Shiki vars work even when Tailwind doesn't scan Streamdown's internal class names. */
|
/* Streamdown code blocks: ensure light/dark Shiki vars work even when Tailwind doesn't scan Streamdown's internal class names. */
|
||||||
.streamdown-content [data-streamdown="code-block-body"] {
|
.streamdown-content [data-streamdown="code-block-body"] {
|
||||||
font-size: var(--text-code);
|
font-size: var(--text-code);
|
||||||
background-color: var(--shiki-light-bg, transparent);
|
background-color: transparent;
|
||||||
color: var(--shiki-light, inherit);
|
color: var(--shiki-light, inherit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.streamdown-content [data-streamdown="code-block-body"] span[style*="--sdm-c"] {
|
||||||
|
color: var(--sdm-c) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamdown-content [data-streamdown="code-block-body"] span[style*="--sdm-tbg"] {
|
||||||
|
background-color: var(--sdm-tbg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamdown-content [data-streamdown="code-block-body"] span[style*="--shiki-light"] {
|
||||||
|
color: var(--shiki-light) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamdown-content [data-streamdown="code-block-body"] span[style*="--shiki-light-bg"] {
|
||||||
|
background-color: var(--shiki-light-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.dark .streamdown-content [data-streamdown="code-block-body"] {
|
.dark .streamdown-content [data-streamdown="code-block-body"] {
|
||||||
background-color: var(--shiki-dark-bg, var(--shiki-light-bg, transparent));
|
background-color: transparent;
|
||||||
color: var(--shiki-dark, var(--shiki-light, inherit));
|
color: var(--shiki-dark, var(--shiki-light, inherit));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,6 +854,10 @@ html:not(.dark) .chat-scroll {
|
|||||||
color: var(--shiki-dark) !important;
|
color: var(--shiki-dark) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .streamdown-content [data-streamdown="code-block-body"] span[style*="--shiki-dark-bg"] {
|
||||||
|
background-color: var(--shiki-dark-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Fix list styling - override Tailwind reset */
|
/* Fix list styling - override Tailwind reset */
|
||||||
.streamdown-content ul {
|
.streamdown-content ul {
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
@@ -857,13 +877,125 @@ html:not(.dark) .chat-scroll {
|
|||||||
|
|
||||||
/* Reduce code block header height */
|
/* Reduce code block header height */
|
||||||
[data-streamdown="code-block-header"] {
|
[data-streamdown="code-block-header"] {
|
||||||
|
margin: 0;
|
||||||
padding: 0.375rem 0.75rem;
|
padding: 0.375rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding-right: 5.25rem;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--interactive-border);
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--surface-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Disable Streamdown's code block virtualization (content-visibility) to avoid late height changes that break scroll-to-bottom. */
|
/* Disable Streamdown's code block virtualization and keep block chrome fixed */
|
||||||
.streamdown-content [data-streamdown="code-block"] {
|
.streamdown-content [data-streamdown="code-block"] {
|
||||||
content-visibility: visible !important;
|
content-visibility: visible !important;
|
||||||
contain-intrinsic-size: none !important;
|
contain-intrinsic-size: none !important;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
inline-size: 100%;
|
||||||
|
max-inline-size: 100%;
|
||||||
|
min-inline-size: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-background);
|
||||||
|
border: 1px solid var(--interactive-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamdown-content [data-streamdown="code-block"] > div:not([data-streamdown]) {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
height: 2rem;
|
||||||
|
z-index: 6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Streamdown code block body: scroll text content within the card body */
|
||||||
|
.streamdown-content [data-streamdown="code-block-body"] {
|
||||||
|
display: block;
|
||||||
|
inline-size: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamdown-content [data-streamdown="code-block-body"] pre {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0.75rem !important;
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamdown-content [data-streamdown="code-block-body"] > * {
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamdown-content [data-streamdown="code-block-body"] code {
|
||||||
|
white-space: pre;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
word-break: normal;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamdown-content [data-streamdown="code-block-actions"] {
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
border: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamdown-content [data-streamdown="code-block-actions"] button {
|
||||||
|
border: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0.25rem !important;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
min-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamdown-content [data-streamdown="code-block-actions"] button svg {
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.streamdown-content [data-streamdown="code-block"] [data-streamdown="code-block-actions"] {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streamdown-content [data-streamdown="code-block"]:hover [data-streamdown="code-block-actions"],
|
||||||
|
.streamdown-content [data-streamdown="code-block"]:focus-within [data-streamdown="code-block-actions"] {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mermaid block: position context for controls wrapper. */
|
/* Mermaid block: position context for controls wrapper. */
|
||||||
|
|||||||
@@ -150,6 +150,13 @@
|
|||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep Streamdown code blocks horizontally scrollable on mobile. */
|
||||||
|
:root.mobile-pointer:not(.desktop-runtime) .streamdown-content [data-streamdown="code-block-body"] {
|
||||||
|
overflow-x: auto !important;
|
||||||
|
overflow-y: hidden !important;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
/* Fix mobile viewport height */
|
/* Fix mobile viewport height */
|
||||||
:root.mobile-pointer:not(.desktop-runtime),
|
:root.mobile-pointer:not(.desktop-runtime),
|
||||||
:root.mobile-pointer:not(.desktop-runtime) body {
|
:root.mobile-pointer:not(.desktop-runtime) body {
|
||||||
|
|||||||
Reference in New Issue
Block a user