From d2d39c48ac268ba1a47bafa09245decf77f2522f Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Tue, 24 Feb 2026 01:49:06 +0200 Subject: [PATCH] feat(chat): add Mermaid fullscreen preview and harden file preview paths (#490) * feat(chat): add mermaid preview popups and fullscreen diagram viewer Enable opening Mermaid diagrams from markdown and file attachments with a dedicated fullscreen dialog, while tightening preview loading behavior and sizing for more reliable interaction. * refactor(chat): extract shared preview overlay hooks Centralize fullscreen preview transition and viewport lifecycle logic so image and Mermaid dialogs stay behaviorally aligned while reducing maintenance overhead. * fix(security): enforce workspace boundaries for fs read endpoints Validate /api/fs/read and /api/fs/raw paths against active workspace roots and canonical realpaths to block traversal and symlink escapes before serving file contents. * fix(chat): remove dead Mermaid copy component Drop an unused merge-leftover component in MarkdownRenderer to keep lint clean without changing Mermaid preview behavior. --- .../ui/src/components/chat/ChatMessage.tsx | 2 +- .../ui/src/components/chat/FileAttachment.tsx | 114 +++- .../src/components/chat/MarkdownRenderer.tsx | 214 ++++--- .../components/chat/message/MessageBody.tsx | 6 +- .../chat/message/ToolOutputDialog.tsx | 562 ++++++++++++++++-- .../chat/message/parts/ProgressiveGroup.tsx | 2 + .../chat/message/parts/ToolPart.tsx | 29 +- .../ui/src/components/chat/message/types.ts | 6 + packages/ui/src/index.css | 63 ++ packages/ui/src/lib/opencode/client.ts | 1 + packages/web/server/index.js | 40 +- 11 files changed, 882 insertions(+), 157 deletions(-) diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 42b8a851..adf29db0 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -772,7 +772,7 @@ const ChatMessage: React.FC = ({ const handleShowPopup = React.useCallback((content: ToolPopupContent) => { - if (content.image) { + if (content.image || content.mermaid) { setPopupContent(content); setImagePreviewOpen(true); } diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index 1f50d05f..4860d0cb 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -275,8 +275,62 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false } return ; }; + const extractExtension = (value?: string): string => { + if (!value) return ''; + const normalized = value.replace(/\\/g, '/').trim(); + const filename = normalized.split('/').pop() || normalized; + const dotIndex = filename.lastIndexOf('.'); + if (dotIndex < 0) return ''; + return filename.slice(dotIndex).toLowerCase(); + }; + + const isMermaidMimeType = (mimeType?: string): boolean => { + if (!mimeType) return false; + const normalized = mimeType.toLowerCase(); + return normalized === 'text/vnd.mermaid' + || normalized === 'application/vnd.mermaid' + || normalized === 'text/x-mermaid' + || normalized === 'application/x-mermaid'; + }; + + const isGenericTextOrUnknownMime = (mimeType?: string): boolean => { + if (!mimeType) return true; + const normalized = mimeType.toLowerCase(); + return normalized === 'text/plain' || normalized === 'application/octet-stream'; + }; + + const isExplicitBinaryMime = (mimeType?: string): boolean => { + if (!mimeType) return false; + const normalized = mimeType.toLowerCase(); + return normalized.startsWith('image/') + || normalized.startsWith('video/') + || normalized.startsWith('audio/') + || normalized === 'application/pdf'; + }; + + const isMermaidFile = (file: FilePart): boolean => { + const normalizedMime = file.mime?.toLowerCase(); + if (isMermaidMimeType(normalizedMime)) { + return true; + } + + if (isExplicitBinaryMime(normalizedMime)) { + return false; + } + + const extension = extractExtension(file.filename || file.url); + const isMermaidExtension = extension === '.mmd' || extension === '.mermaid'; + return isMermaidExtension && isGenericTextOrUnknownMime(normalizedMime); + }; + const imageFiles = fileItems.filter(f => f.mime?.startsWith('image/') && f.url); - const otherFiles = fileItems.filter(f => !f.mime?.startsWith('image/')); + const mermaidFiles = fileItems.filter((file) => isMermaidFile(file) && file.url); + const otherFiles = fileItems.filter((file) => { + if (file.mime?.startsWith('image/')) { + return false; + } + return !isMermaidFile(file); + }); const imageGallery = React.useMemo( () => @@ -326,6 +380,30 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false } onShowPopup(popupPayload); }, [imageGallery, onShowPopup]); + const handleMermaidClick = React.useCallback((file: FilePart) => { + if (!onShowPopup || !file.url) { + return; + } + + const filename = extractFilename(file.filename) || 'Diagram'; + onShowPopup({ + open: true, + title: filename, + content: '', + metadata: { + tool: 'mermaid-preview', + filename, + mime: file.mime, + size: file.size, + }, + mermaid: { + url: file.url, + mimeType: file.mime, + filename, + }, + }); + }, [onShowPopup]); + if (fileItems.length === 0) return null; return ( @@ -335,7 +413,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
{otherFiles.map((file, index) => (
)} + {mermaidFiles.length > 0 && ( +
+ {mermaidFiles.map((file, index) => { + const filename = extractFilename(file.filename) || 'Diagram'; + + return ( + + ); + })} +
+ )} + {} {imageFiles.length > 0 && (
@@ -360,7 +468,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false } const filename = extractFilename(file.filename) || 'Image'; return ( - + - ); +type MermaidControlOptions = { + download: boolean; + copy: boolean; + fullscreen: boolean; + panZoom: boolean; }; -// Mermaid download button — downloads rendered SVG -const MermaidDownloadButton: React.FC<{ containerRef: React.RefObject }> = ({ containerRef }) => { - const handleDownload = () => { - const svgEl = containerRef.current?.querySelector('svg'); - if (!(svgEl instanceof SVGElement)) return; - const serializer = new XMLSerializer(); - let markup = serializer.serializeToString(svgEl); - if (!markup.includes('xmlns=')) { - markup = markup.replace(' { + const blocks: string[] = []; + const regex = /(?:^|\r?\n)(`{3,}|~{3,})mermaid[^\n\r]*\r?\n([\s\S]*?)\r?\n\1(?=\r?\n|$)/gi; + let match: RegExpExecArray | null = regex.exec(markdown); - return ( - - ); -}; + while (match) { + const block = (match[2] ?? '').replace(/\s+$/, ''); + blocks.push(block); + match = regex.exec(markdown); + } -// Mermaid wrapper with custom controls (same pattern as TableWrapper) -const MermaidWrapper: React.FC<{ children: React.ReactNode; source: string }> = ({ children, source }) => { - const containerRef = React.useRef(null); - - return ( -
- {children} -
- - -
-
- ); + return blocks; }; export type MarkdownVariant = 'assistant' | 'tool'; @@ -618,8 +578,104 @@ interface MarkdownRendererProps { className?: string; isStreaming?: boolean; variant?: MarkdownVariant; + onShowPopup?: (content: ToolPopupContent) => void; } +const MERMAID_BLOCK_SELECTOR = '[data-streamdown="mermaid-block"]'; + +const useMermaidInlineInteractions = ({ + containerRef, + mermaidBlocks, + onShowPopup, + allowWheelZoom, +}: { + containerRef: React.RefObject; + mermaidBlocks: string[]; + onShowPopup?: (content: ToolPopupContent) => void; + allowWheelZoom?: boolean; +}) => { + React.useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const handleMermaidClick = (event: MouseEvent) => { + if (!onShowPopup) { + return; + } + + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + if (target.closest('button, a, [role="button"]')) { + return; + } + + const block = target.closest(MERMAID_BLOCK_SELECTOR); + if (!block) { + return; + } + + const renderedBlocks = Array.from(container.querySelectorAll(MERMAID_BLOCK_SELECTOR)); + const blockIndex = renderedBlocks.indexOf(block); + if (blockIndex < 0) { + return; + } + + const source = mermaidBlocks[blockIndex]; + if (!source || source.trim().length === 0) { + return; + } + + const filename = `Diagram ${blockIndex + 1}`; + onShowPopup({ + open: true, + title: filename, + content: '', + metadata: { + tool: 'mermaid-preview', + filename, + }, + mermaid: { + url: `data:text/plain;charset=utf-8,${encodeURIComponent(source)}`, + source, + filename, + }, + }); + }; + + const handleInlineWheel = (event: WheelEvent) => { + if (allowWheelZoom) { + return; + } + + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const block = target.closest(MERMAID_BLOCK_SELECTOR); + if (!block) { + return; + } + + // Keep regular page scroll while preventing Streamdown inline wheel-zoom handlers. + event.stopPropagation(); + }; + + container.addEventListener('click', handleMermaidClick); + container.addEventListener('wheel', handleInlineWheel, { capture: true, passive: true }); + + return () => { + container.removeEventListener('click', handleMermaidClick); + container.removeEventListener('wheel', handleInlineWheel, true); + }; + }, [allowWheelZoom, containerRef, mermaidBlocks, onShowPopup]); +}; + export const MarkdownRenderer: React.FC = ({ content, part, @@ -628,7 +684,12 @@ export const MarkdownRenderer: React.FC = ({ className, isStreaming = false, variant = 'assistant', + onShowPopup, }) => { + const streamdownContainerRef = React.useRef(null); + const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]); + useMermaidInlineInteractions({ containerRef: streamdownContainerRef, mermaidBlocks, onShowPopup }); + const shikiThemes = useMarkdownShikiThemes(); const { options: mermaidOptions, mermaidRenderKey } = useStreamdownMermaidOptions(); const componentKey = React.useMemo(() => { @@ -641,7 +702,7 @@ export const MarkdownRenderer: React.FC = ({ : 'streamdown-content'; const markdownContent = ( -
+
= ({ content, className, variant = 'assistant' }) => { + onShowPopup?: (content: ToolPopupContent) => void; + mermaidControls?: MermaidControlOptions; + allowMermaidWheelZoom?: boolean; +}> = ({ content, className, variant = 'assistant', onShowPopup, mermaidControls, allowMermaidWheelZoom = false }) => { + const streamdownContainerRef = React.useRef(null); + const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]); + useMermaidInlineInteractions({ + containerRef: streamdownContainerRef, + mermaidBlocks, + onShowPopup, + allowWheelZoom: allowMermaidWheelZoom, + }); + const shikiThemes = useMarkdownShikiThemes(); const { options: mermaidOptions, mermaidRenderKey } = useStreamdownMermaidOptions(); @@ -681,13 +754,16 @@ export const SimpleMarkdownRenderer: React.FC<{ : 'streamdown-content'; return ( -
+
> = ({ syntaxTheme={syntaxTheme} isMobile={isMobile} onContentChange={onContentChange} + onShowPopup={onShowPopup} hasPrevTool={false} hasNextTool={false} /> @@ -997,6 +998,7 @@ const AssistantMessageBody: React.FC> = ({ syntaxTheme={syntaxTheme} isMobile={isMobile} onContentChange={onContentChange} + onShowPopup={onShowPopup} hasPrevTool={connection?.hasPrev ?? false} hasNextTool={connection?.hasNext ?? false} /> @@ -1242,7 +1244,7 @@ const AssistantMessageBody: React.FC> = ({ {showErrorMessage && (
- +
)} @@ -1251,7 +1253,7 @@ const AssistantMessageBody: React.FC> = ({
- + {shouldShowFooter && (
diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx index ac01b4d1..d9a1be22 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Dialog, DialogContent } from '@/components/ui/dialog'; -import { RiArrowLeftSLine, RiArrowRightSLine, RiBrainAi3Line, RiCloseLine, RiFileImageLine, RiFileList2Line, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiPencilAiLine, RiSearchLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react'; +import { RiArrowLeftSLine, RiArrowRightSLine, RiBrainAi3Line, RiCloseLine, RiFileImageLine, RiFileList2Line, RiFilePdfLine, RiFileSearchLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck3, RiLoader4Line, RiPencilAiLine, RiSearchLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { createPortal } from 'react-dom'; @@ -42,6 +42,9 @@ const getToolIcon = (toolName: string) => { if (tool === 'image-preview') { return ; } + if (tool === 'mermaid-preview') { + return ; + } if (tool === 'edit' || tool === 'multiedit' || tool === 'apply_patch' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') { return ; } @@ -84,7 +87,157 @@ const getToolIcon = (toolName: string) => { return ; }; -const IMAGE_PREVIEW_ANIMATION_MS = 150; +const PREVIEW_ANIMATION_MS = 150; +const MERMAID_DIALOG_HEADER_HEIGHT = 40; +const MERMAID_ASPECT_RETRY_DELAY_MS = 120; +const MERMAID_ASPECT_MAX_RETRIES = 3; + +type ViewportSize = { width: number; height: number }; + +const getWindowViewport = (): ViewportSize => ({ + width: typeof window !== 'undefined' ? window.innerWidth : 0, + height: typeof window !== 'undefined' ? window.innerHeight : 0, +}); + +const PREVIEW_VIEWPORT_LIMITS = { + mobile: { widthRatio: 0.94, heightRatio: 0.86, padding: 10 }, + desktop: { widthRatio: 0.8, heightRatio: 0.8, padding: 16 }, +} as const; + +const getPreviewViewportBounds = (viewport: { width: number; height: number }, isMobile: boolean) => { + const limits = isMobile ? PREVIEW_VIEWPORT_LIMITS.mobile : PREVIEW_VIEWPORT_LIMITS.desktop; + const paddedWidth = Math.max(160, viewport.width - limits.padding * 2); + const paddedHeight = Math.max(160, viewport.height - limits.padding * 2); + + return { + maxWidth: Math.max(160, Math.min(paddedWidth, viewport.width * limits.widthRatio)), + maxHeight: Math.max(160, Math.min(paddedHeight, viewport.height * limits.heightRatio)), + }; +}; + +const getSvgAspectRatio = (svg: SVGElement): number | null => { + try { + const groups = Array.from(svg.querySelectorAll('g')); + let bestArea = 0; + let bestRatio: number | null = null; + + for (const group of groups) { + if (!(group instanceof SVGGraphicsElement)) { + continue; + } + const box = group.getBBox(); + if (!(box.width > 0 && box.height > 0)) { + continue; + } + const area = box.width * box.height; + if (area > bestArea) { + bestArea = area; + bestRatio = box.width / box.height; + } + } + + if (bestRatio && Number.isFinite(bestRatio) && bestRatio > 0) { + return bestRatio; + } + } catch { + // Ignore getBBox failures and fall back to SVG attrs/viewBox. + } + + const viewBox = svg.getAttribute('viewBox'); + if (viewBox) { + const parts = viewBox.trim().split(/\s+/).map(Number); + if (parts.length === 4) { + const width = parts[2]; + const height = parts[3]; + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { + return width / height; + } + } + } + + const attrWidth = Number(svg.getAttribute('width')); + const attrHeight = Number(svg.getAttribute('height')); + if (Number.isFinite(attrWidth) && Number.isFinite(attrHeight) && attrWidth > 0 && attrHeight > 0) { + return attrWidth / attrHeight; + } + + const rect = svg.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + return rect.width / rect.height; + } + + return null; +}; + +const usePreviewOverlayState = (open: boolean) => { + const [isRendered, setIsRendered] = React.useState(open); + const [isVisible, setIsVisible] = React.useState(open); + const [isTransitioning, setIsTransitioning] = React.useState(false); + + React.useEffect(() => { + if (open) { + setIsRendered(true); + setIsTransitioning(true); + if (typeof window === 'undefined') { + setIsVisible(true); + return; + } + + const raf = window.requestAnimationFrame(() => { + setIsVisible(true); + }); + + const doneId = window.setTimeout(() => { + setIsTransitioning(false); + }, PREVIEW_ANIMATION_MS); + + return () => { + window.cancelAnimationFrame(raf); + window.clearTimeout(doneId); + }; + } + + setIsVisible(false); + setIsTransitioning(true); + if (typeof window === 'undefined') { + setIsRendered(false); + return; + } + + const timeoutId = window.setTimeout(() => { + setIsRendered(false); + setIsTransitioning(false); + }, PREVIEW_ANIMATION_MS); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [open]); + + return { isRendered, isVisible, isTransitioning }; +}; + +const usePreviewViewport = (open: boolean) => { + const [viewport, setViewport] = React.useState(getWindowViewport); + + React.useEffect(() => { + if (!open || typeof window === 'undefined') { + return; + } + + const onResize = () => { + setViewport(getWindowViewport()); + }; + + onResize(); + window.addEventListener('resize', onResize); + return () => { + window.removeEventListener('resize', onResize); + }; + }, [open]); + + return viewport; +}; const ImagePreviewDialog: React.FC<{ popup: ToolPopupContent; @@ -112,13 +265,8 @@ const ImagePreviewDialog: React.FC<{ const [currentIndex, setCurrentIndex] = React.useState(0); const [imageNaturalSize, setImageNaturalSize] = React.useState<{ width: number; height: number } | null>(null); - const [isRendered, setIsRendered] = React.useState(popup.open); - const [isVisible, setIsVisible] = React.useState(popup.open); - const [isTransitioning, setIsTransitioning] = React.useState(false); - const [viewport, setViewport] = React.useState<{ width: number; height: number }>({ - width: typeof window !== 'undefined' ? window.innerWidth : 0, - height: typeof window !== 'undefined' ? window.innerHeight : 0, - }); + const { isRendered, isVisible, isTransitioning } = usePreviewOverlayState(popup.open); + const viewport = usePreviewViewport(popup.open); React.useEffect(() => { if (!popup.open || gallery.length === 0) { @@ -151,46 +299,6 @@ const ImagePreviewDialog: React.FC<{ setCurrentIndex((prev) => (prev + 1) % gallery.length); }, [gallery.length]); - React.useEffect(() => { - if (popup.open) { - setIsRendered(true); - setIsTransitioning(true); - if (typeof window === 'undefined') { - setIsVisible(true); - return; - } - - const raf = window.requestAnimationFrame(() => { - setIsVisible(true); - }); - - const doneId = window.setTimeout(() => { - setIsTransitioning(false); - }, IMAGE_PREVIEW_ANIMATION_MS); - - return () => { - window.cancelAnimationFrame(raf); - window.clearTimeout(doneId); - }; - } - - setIsVisible(false); - setIsTransitioning(true); - if (typeof window === 'undefined') { - setIsRendered(false); - return; - } - - const timeoutId = window.setTimeout(() => { - setIsRendered(false); - setIsTransitioning(false); - }, IMAGE_PREVIEW_ANIMATION_MS); - - return () => { - window.clearTimeout(timeoutId); - }; - }, [popup.open]); - React.useEffect(() => { if (!popup.open) { return; @@ -220,22 +328,6 @@ const ImagePreviewDialog: React.FC<{ }; }, [hasMultipleImages, onOpenChange, popup.open, showNext, showPrevious]); - React.useEffect(() => { - if (!popup.open || typeof window === 'undefined') { - return; - } - - const onResize = () => { - setViewport({ width: window.innerWidth, height: window.innerHeight }); - }; - - onResize(); - window.addEventListener('resize', onResize); - return () => { - window.removeEventListener('resize', onResize); - }; - }, [popup.open]); - React.useEffect(() => { setImageNaturalSize(null); }, [currentImage?.url]); @@ -453,6 +545,342 @@ const DialogReadContent: React.FC<{ }); DialogReadContent.displayName = 'DialogReadContent'; +const MermaidPreviewDialog: React.FC<{ + popup: ToolPopupContent; + onOpenChange: (open: boolean) => void; + isMobile: boolean; +}> = ({ popup, onOpenChange, isMobile }) => { + const [source, setSource] = React.useState(popup.mermaid?.source || ''); + const [status, setStatus] = React.useState<'idle' | 'loading' | 'ready' | 'error'>(popup.mermaid?.source ? 'ready' : 'idle'); + const [errorMessage, setErrorMessage] = React.useState(''); + const { isRendered, isVisible, isTransitioning } = usePreviewOverlayState(popup.open); + const [diagramAspectRatio, setDiagramAspectRatio] = React.useState(null); + const viewport = usePreviewViewport(popup.open); + const requestIdRef = React.useRef(0); + const mermaidPreviewRef = React.useRef(null); + + const normalizeFilePath = React.useCallback((rawPath: string): string | null => { + const input = rawPath.trim(); + if (!input.toLowerCase().startsWith('file://')) { + return null; + } + + const isSafeLocalPath = (path: string): boolean => { + if (!path || /[\0\r\n]/.test(path)) { + return false; + } + + const normalized = path.replace(/\\/g, '/'); + const segments = normalized.split('/').filter(Boolean); + if (segments.includes('..')) { + return false; + } + + if (normalized.startsWith('/')) { + return true; + } + + return /^[A-Za-z]:\//.test(normalized); + }; + + try { + let pathname = decodeURIComponent(new URL(input).pathname || ''); + if (/^\/[A-Za-z]:\//.test(pathname)) { + pathname = pathname.slice(1); + } + return isSafeLocalPath(pathname) ? pathname : null; + } catch { + const stripped = input.replace(/^file:\/\//i, ''); + try { + const decoded = decodeURIComponent(stripped); + return isSafeLocalPath(decoded) ? decoded : null; + } catch { + return isSafeLocalPath(stripped) ? stripped : null; + } + } + }, []); + + const decodeDataUrl = React.useCallback((value: string): string => { + const commaIndex = value.indexOf(','); + if (commaIndex < 0) { + throw new Error('Malformed data URL'); + } + + const metadata = value.slice(0, commaIndex).toLowerCase(); + const payload = value.slice(commaIndex + 1); + if (metadata.includes(';base64')) { + return atob(payload); + } + return decodeURIComponent(payload); + }, []); + + const loadMermaidSource = React.useCallback(async () => { + const target = popup.mermaid; + if (!target?.url) { + setStatus('error'); + setErrorMessage('Missing Mermaid source URL.'); + return; + } + + if (target.source) { + setSource(target.source); + setStatus('ready'); + setErrorMessage(''); + return; + } + + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + + setStatus('loading'); + setErrorMessage(''); + + try { + let resolvedSource = ''; + if (target.url.startsWith('data:')) { + resolvedSource = decodeDataUrl(target.url); + } else if (target.url.toLowerCase().startsWith('file://')) { + const normalizedPath = normalizeFilePath(target.url); + if (!normalizedPath) { + throw new Error('Invalid local file path for Mermaid preview.'); + } + const response = await fetch(`/api/fs/raw?path=${encodeURIComponent(normalizedPath)}`); + if (!response.ok) { + throw new Error(`Failed to read diagram file (${response.status})`); + } + resolvedSource = await response.text(); + } else { + const resolvedUrl = new URL(target.url, window.location.origin); + if (resolvedUrl.protocol !== 'http:' && resolvedUrl.protocol !== 'https:') { + throw new Error('Unsupported Mermaid URL protocol.'); + } + + const response = await fetch(resolvedUrl.toString()); + if (!response.ok) { + throw new Error(`Failed to load diagram (${response.status})`); + } + resolvedSource = await response.text(); + } + + if (requestIdRef.current !== requestId) { + return; + } + + setSource(resolvedSource); + setStatus('ready'); + } catch (error) { + if (requestIdRef.current !== requestId) { + return; + } + setStatus('error'); + setErrorMessage(error instanceof Error ? error.message : 'Unable to load Mermaid diagram.'); + } + }, [decodeDataUrl, normalizeFilePath, popup.mermaid]); + + React.useEffect(() => { + if (!popup.open || !popup.mermaid) { + return; + } + void loadMermaidSource(); + }, [loadMermaidSource, popup.mermaid, popup.open]); + + React.useEffect(() => { + if (!popup.open) { + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onOpenChange(false); + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => { + window.removeEventListener('keydown', onKeyDown); + }; + }, [onOpenChange, popup.open]); + + React.useEffect(() => { + if (!popup.open || status !== 'ready') { + setDiagramAspectRatio(null); + return; + } + + const measureAspectRatio = () => { + const svg = mermaidPreviewRef.current?.querySelector('svg'); + if (!svg) { + return false; + } + + const aspectRatio = getSvgAspectRatio(svg as SVGElement); + if (!aspectRatio || !Number.isFinite(aspectRatio) || aspectRatio <= 0) { + return false; + } + + setDiagramAspectRatio((previous) => { + if (previous && Math.abs(previous - aspectRatio) < 0.001) { + return previous; + } + return aspectRatio; + }); + return true; + }; + + let rafId = window.requestAnimationFrame(() => { + if (!measureAspectRatio()) { + rafId = window.requestAnimationFrame(() => { + measureAspectRatio(); + }); + } + }); + + let retryCount = 0; + let timeoutId: number | undefined; + const scheduleRetry = () => { + if (retryCount >= MERMAID_ASPECT_MAX_RETRIES) { + return; + } + + timeoutId = window.setTimeout(() => { + retryCount += 1; + if (!measureAspectRatio()) { + scheduleRetry(); + } + }, MERMAID_ASPECT_RETRY_DELAY_MS); + }; + scheduleRetry(); + + const observer = new MutationObserver(() => { + measureAspectRatio(); + }); + + if (mermaidPreviewRef.current) { + observer.observe(mermaidPreviewRef.current, { childList: true, subtree: true, attributes: true }); + } + + return () => { + window.cancelAnimationFrame(rafId); + if (typeof timeoutId === 'number') { + window.clearTimeout(timeoutId); + } + observer.disconnect(); + }; + }, [popup.open, source, status]); + + const mermaidMarkdown = `\`\`\`mermaid\n${source}\n\`\`\``; + + const dialogSize = React.useMemo(() => { + const { maxWidth, maxHeight } = getPreviewViewportBounds(viewport, isMobile); + const availableDiagramHeight = Math.max(160, maxHeight - MERMAID_DIALOG_HEADER_HEIGHT); + + if (diagramAspectRatio && diagramAspectRatio < 1) { + const squareSide = Math.min(maxWidth, availableDiagramHeight); + return { width: Math.round(squareSide), height: Math.round(squareSide) }; + } + + return { width: Math.round(maxWidth), height: Math.round(availableDiagramHeight) }; + }, [diagramAspectRatio, isMobile, viewport]); + + if (!isRendered || typeof document === 'undefined') { + return null; + } + + const content = ( +
+ + ); + + return createPortal(content, document.body); +}; const ToolOutputDialog: React.FC = ({ popup, onOpenChange, syntaxTheme, isMobile }) => { const [diffViewMode, setDiffViewMode] = React.useState(isMobile ? 'unified' : 'side-by-side'); @@ -461,6 +889,10 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange return ; } + if (popup.mermaid) { + return ; + } + return ( = ({ isMobile, expandedTools, onToggleTool, + onShowPopup, onContentChange, diffStats, }) => { @@ -252,6 +253,7 @@ const ProgressiveGroup: React.FC = ({ syntaxTheme={syntaxTheme} isMobile={isMobile} onContentChange={onContentChange} + onShowPopup={onShowPopup} hasPrevTool={connection?.hasPrev ?? false} hasNextTool={connection?.hasNext ?? false} /> diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 38d16afa..e32d5c93 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -14,6 +14,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { opencodeClient } from '@/lib/opencode/client'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; +import type { ToolPopupContent } from '../types'; import { renderListOutput, @@ -38,6 +39,7 @@ interface ToolPartProps { syntaxTheme: { [key: string]: React.CSSProperties }; isMobile: boolean; onContentChange?: (reason?: ContentChangeReason) => void; + onShowPopup?: (content: ToolPopupContent) => void; hasPrevTool?: boolean; hasNextTool?: boolean; } @@ -456,7 +458,8 @@ const TaskToolSummary: React.FC<{ hasNextTool: boolean; output?: string; sessionId?: string; -}> = ({ entries, isExpanded, hasPrevTool, hasNextTool, output, sessionId }) => { + onShowPopup?: (content: ToolPopupContent) => void; +}> = ({ entries, isExpanded, hasPrevTool, hasNextTool, output, sessionId, onShowPopup }) => { const setCurrentSession = useSessionStore((state) => state.setCurrentSession); const displayEntries = React.useMemo(() => { const nonPending = entries.filter((entry) => entry.state?.status !== 'pending'); @@ -555,7 +558,7 @@ const TaskToolSummary: React.FC<{ {isOutputExpanded ? (
- +
) : null} @@ -770,6 +773,7 @@ interface ToolExpandedContentProps { syntaxTheme: { [key: string]: React.CSSProperties }; isMobile: boolean; currentDirectory: string; + onShowPopup?: (content: ToolPopupContent) => void; hasPrevTool: boolean; hasNextTool: boolean; } @@ -780,6 +784,7 @@ const ToolExpandedContent: React.FC = React.memo(({ syntaxTheme, isMobile, currentDirectory, + onShowPopup, hasPrevTool, hasNextTool, }) => { @@ -946,7 +951,7 @@ const ToolExpandedContent: React.FC = React.memo(({ if (part.tool === 'task' && hasStringOutput) { return renderScrollableBlock(
- +
); } @@ -965,7 +970,7 @@ const ToolExpandedContent: React.FC = React.memo(({ if (part.tool === 'codesearch' && hasStringOutput) { return renderScrollableBlock(
- +
); } @@ -973,7 +978,7 @@ const ToolExpandedContent: React.FC = React.memo(({ if (part.tool === 'skill' && hasStringOutput) { return renderScrollableBlock(
- +
); } @@ -1106,7 +1111,17 @@ const ToolExpandedContent: React.FC = React.memo(({ ToolExpandedContent.displayName = 'ToolExpandedContent'; -const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxTheme, isMobile, onContentChange, hasPrevTool = false, hasNextTool = false }) => { +const ToolPart: React.FC = ({ + part, + isExpanded, + onToggle, + syntaxTheme, + isMobile, + onContentChange, + onShowPopup, + hasPrevTool = false, + hasNextTool = false, +}) => { const state = part.state; const currentDirectory = useDirectoryStore((s) => s.currentDirectory); @@ -1413,6 +1428,7 @@ const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxT hasNextTool={hasNextTool} output={taskOutputString} sessionId={taskSessionId} + onShowPopup={onShowPopup} /> ) : null} @@ -1423,6 +1439,7 @@ const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxT syntaxTheme={syntaxTheme} isMobile={isMobile} currentDirectory={currentDirectory} + onShowPopup={onShowPopup} hasPrevTool={hasPrevTool} hasNextTool={hasNextTool} /> diff --git a/packages/ui/src/components/chat/message/types.ts b/packages/ui/src/components/chat/message/types.ts index 7488dc00..ba9e3b4b 100644 --- a/packages/ui/src/components/chat/message/types.ts +++ b/packages/ui/src/components/chat/message/types.ts @@ -28,4 +28,10 @@ export interface ToolPopupContent { }>; index?: number; }; + mermaid?: { + url: string; + mimeType?: string; + filename?: string; + source?: string; + }; } diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 934da3ee..8d5e6510 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -867,6 +867,69 @@ html:not(.dark) .chat-scroll { /* Mermaid block: position context for controls wrapper. */ [data-streamdown="mermaid-block"] { position: relative; + border-radius: 0.75rem; + border: 1px solid var(--interactive-border); + background: var(--surface-elevated); + padding: 0.5rem; +} + +[data-streamdown="mermaid-block"] [data-streamdown="mermaid"] { + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +[data-streamdown="mermaid-block"] [data-streamdown="mermaid"] svg { + width: 100% !important; + max-width: none !important; + height: auto !important; +} + +[data-streamdown="mermaid-block"] svg { + display: block; + background: var(--surface-elevated); +} + +/* Fullscreen Mermaid popup: scale diagrams to fill available viewport panel area. */ +.streamdown-mermaid-fullscreen { + height: 100%; + width: 100%; +} + +.streamdown-mermaid-fullscreen .streamdown-content { + height: 100%; + width: 100%; +} + +.streamdown-mermaid-fullscreen [data-streamdown="mermaid-block"] { + width: 100%; + height: 100%; + min-height: 100%; + padding: 0; + border: 0; + border-radius: 0; + background: var(--surface-elevated); + display: flex; + align-items: center; + justify-content: center; +} + +.streamdown-mermaid-fullscreen [data-streamdown="mermaid-block"] [data-streamdown="mermaid"] { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.streamdown-mermaid-fullscreen [data-streamdown="mermaid-block"] svg { + width: 100% !important; + height: 100% !important; + max-width: 100% !important; + max-height: 100% !important; + display: block; + background: transparent; } /* Hide system caret in terminal - targets both Ghostty internal input and our custom touch input */ diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 14f51baf..264f02c4 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -484,6 +484,7 @@ class OpencodeService { 'application/x-sh', 'application/x-shellscript', 'application/octet-stream', + 'image/svg+xml', ]; return textBasedTypes.includes(lowerMime); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 4f8ed243..76e5c58c 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -10432,17 +10432,26 @@ Context: } try { - const resolvedPath = path.resolve(normalizeDirectoryPath(filePath)); - if (resolvedPath.includes('..')) { - return res.status(400).json({ error: 'Invalid path: path traversal not allowed' }); + const resolved = await resolveWorkspacePathFromContext(req, filePath); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); } - const stats = await fsPromises.stat(resolvedPath); + const [canonicalPath, canonicalBase] = await Promise.all([ + fsPromises.realpath(resolved.resolved), + fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)), + ]); + + if (!isPathWithinRoot(canonicalPath, canonicalBase)) { + return res.status(403).json({ error: 'Access to file denied' }); + } + + const stats = await fsPromises.stat(canonicalPath); if (!stats.isFile()) { return res.status(400).json({ error: 'Specified path is not a file' }); } - const content = await fsPromises.readFile(resolvedPath, 'utf8'); + const content = await fsPromises.readFile(canonicalPath, 'utf8'); res.type('text/plain').send(content); } catch (error) { const err = error; @@ -10465,17 +10474,26 @@ Context: } try { - const resolvedPath = path.resolve(normalizeDirectoryPath(filePath)); - if (resolvedPath.includes('..')) { - return res.status(400).json({ error: 'Invalid path: path traversal not allowed' }); + const resolved = await resolveWorkspacePathFromContext(req, filePath); + if (!resolved.ok) { + return res.status(400).json({ error: resolved.error }); } - const stats = await fsPromises.stat(resolvedPath); + const [canonicalPath, canonicalBase] = await Promise.all([ + fsPromises.realpath(resolved.resolved), + fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)), + ]); + + if (!isPathWithinRoot(canonicalPath, canonicalBase)) { + return res.status(403).json({ error: 'Access to file denied' }); + } + + const stats = await fsPromises.stat(canonicalPath); if (!stats.isFile()) { return res.status(400).json({ error: 'Specified path is not a file' }); } - const ext = path.extname(resolvedPath).toLowerCase(); + const ext = path.extname(canonicalPath).toLowerCase(); const mimeMap = { '.png': 'image/png', '.jpg': 'image/jpeg', @@ -10489,7 +10507,7 @@ Context: }; const mimeType = mimeMap[ext] || 'application/octet-stream'; - const content = await fsPromises.readFile(resolvedPath); + const content = await fsPromises.readFile(canonicalPath); res.setHeader('Cache-Control', 'no-store'); res.type(mimeType).send(content); } catch (error) {