diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index f067de25..f51e87b8 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Streamdown } from 'streamdown'; -import { code } from '@streamdown/code'; +import { createCodePlugin } from '@streamdown/code'; import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid'; import 'streamdown/styles.css'; import { FadeInOnReveal } from './message/FadeInOnReveal'; @@ -124,6 +124,20 @@ const useMarkdownShikiThemes = (): readonly [string | object, string | object] = return isVSCode ? themes : fallbackThemes; }; +type StreamdownCodeThemes = NonNullable[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 themeSystem = useOptionalThemeSystem(); const fallbackLight = getDefaultTheme(false); @@ -543,9 +557,6 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou }; const CodeBlockWrapper: React.FC = ({ children, className, style, ...props }) => { - const [copied, setCopied] = React.useState(false); - const codeRef = React.useRef(null); - const { isMobile } = useDeviceInfo(); const mermaidInfo = getMermaidInfo(children); const mermaidRenderingMode = useUIStore((state) => state.mermaidRenderingMode); const codeChild = React.useMemo( @@ -583,7 +594,6 @@ const CodeBlockWrapper: React.FC = ({ children, className const bg = normalizeDeclarationString((style as React.CSSProperties).backgroundColor); if (bg.value) { next.backgroundColor = bg.value; - (next as Record)['--shiki-light-bg'] = bg.value; } for (const [k, v] of Object.entries(bg.vars)) { (next as Record)[k] = v; @@ -592,7 +602,6 @@ const CodeBlockWrapper: React.FC = ({ children, className const fg = normalizeDeclarationString((style as React.CSSProperties).color); if (fg.value) { next.color = fg.value; - (next as Record)['--shiki-light'] = fg.value; } for (const [k, v] of Object.entries(fg.vars)) { (next as Record)[k] = v; @@ -605,49 +614,14 @@ const CodeBlockWrapper: React.FC = ({ children, className return ; } - 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 ( -
-
-        {codeChild}
-      
-
- -
-
+
+      {codeChild}
+    
); }; @@ -656,12 +630,8 @@ const streamdownComponents = { table: TableWrapper, }; -const streamdownPlugins = { - code, -}; - const streamdownControls = { - code: false, + code: true, table: false, }; @@ -821,6 +791,7 @@ export const MarkdownRenderer: React.FC = ({ useMermaidInlineInteractions({ containerRef: streamdownContainerRef, mermaidBlocks, onShowPopup }); const shikiThemes = useMarkdownShikiThemes(); + const streamdownPlugins = useStreamdownPlugins(shikiThemes); const currentMermaidTheme = useCurrentMermaidTheme(); const componentKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`; @@ -829,7 +800,7 @@ export const MarkdownRenderer: React.FC = ({ : 'streamdown-content'; const markdownContent = ( -
+
= ({ controls={streamdownControls} plugins={streamdownPlugins} components={streamdownComponents} - > + > {content}
@@ -887,6 +858,7 @@ export const SimpleMarkdownRenderer: React.FC<{ }); const shikiThemes = useMarkdownShikiThemes(); + const streamdownPlugins = useStreamdownPlugins(shikiThemes); const currentMermaidTheme = useCurrentMermaidTheme(); const streamdownClassName = variant === 'tool' @@ -894,7 +866,7 @@ export const SimpleMarkdownRenderer: React.FC<{ : 'streamdown-content'; return ( -
+
= React.m return (
{prismThemeCss ? : null} {lines.map((line, idx) => ( @@ -256,7 +256,7 @@ const VirtualizedRows: React.FC = React.memo(({
{prismThemeCss ? : null}
= React.memo(({ line, language, showLineNumbers, s
) : (
)} diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index ebdc4561..00264f4f 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -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-content [data-streamdown="code-block-body"] { font-size: var(--text-code); - background-color: var(--shiki-light-bg, transparent); + background-color: transparent; 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"] { - background-color: var(--shiki-dark-bg, var(--shiki-light-bg, transparent)); + background-color: transparent; color: var(--shiki-dark, var(--shiki-light, inherit)); } @@ -838,6 +854,10 @@ html:not(.dark) .chat-scroll { 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 */ .streamdown-content ul { list-style-type: disc; @@ -857,13 +877,125 @@ html:not(.dark) .chat-scroll { /* Reduce code block header height */ [data-streamdown="code-block-header"] { + margin: 0; 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"] { content-visibility: visible !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. */ diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index af7cf6b4..b5c8b990 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -150,6 +150,13 @@ -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 */ :root.mobile-pointer:not(.desktop-runtime), :root.mobile-pointer:not(.desktop-runtime) body {