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.
This commit is contained in:
@@ -772,7 +772,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
|
|
||||||
const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
|
const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
|
||||||
|
|
||||||
if (content.image) {
|
if (content.image || content.mermaid) {
|
||||||
setPopupContent(content);
|
setPopupContent(content);
|
||||||
setImagePreviewOpen(true);
|
setImagePreviewOpen(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,8 +275,62 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
return <RiFileLine className="h-3.5 w-3.5" />;
|
return <RiFileLine className="h-3.5 w-3.5" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 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(
|
const imageGallery = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -326,6 +380,30 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
onShowPopup(popupPayload);
|
onShowPopup(popupPayload);
|
||||||
}, [imageGallery, onShowPopup]);
|
}, [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;
|
if (fileItems.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -335,7 +413,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
<div className={cn('flex flex-wrap', compact ? 'gap-1.5' : 'gap-2')}>
|
<div className={cn('flex flex-wrap', compact ? 'gap-1.5' : 'gap-2')}>
|
||||||
{otherFiles.map((file, index) => (
|
{otherFiles.map((file, index) => (
|
||||||
<div
|
<div
|
||||||
key={`file-${index}`}
|
key={`file-${file.url || file.filename || index}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center bg-muted/30 border border-border/30 typography-meta',
|
'inline-flex items-center bg-muted/30 border border-border/30 typography-meta',
|
||||||
compact ? 'gap-1 px-2 py-0.5 rounded-lg' : 'gap-1.5 px-2.5 py-1 rounded-xl'
|
compact ? 'gap-1 px-2 py-0.5 rounded-lg' : 'gap-1.5 px-2.5 py-1 rounded-xl'
|
||||||
@@ -352,6 +430,36 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mermaidFiles.length > 0 && (
|
||||||
|
<div className={cn('flex flex-wrap', compact ? 'gap-1.5' : 'gap-2')}>
|
||||||
|
{mermaidFiles.map((file, index) => {
|
||||||
|
const filename = extractFilename(file.filename) || 'Diagram';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`mermaid-${file.url || file.filename || index}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMermaidClick(file)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center bg-muted/30 border border-border/30 typography-meta transition-colors',
|
||||||
|
'hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
|
||||||
|
compact ? 'gap-1 px-2 py-0.5 rounded-lg' : 'gap-1.5 px-2.5 py-1 rounded-xl'
|
||||||
|
)}
|
||||||
|
title={`Open ${filename}`}
|
||||||
|
aria-label={`Open diagram ${filename}`}
|
||||||
|
>
|
||||||
|
{getFileIcon(file.mime)}
|
||||||
|
<div className={cn('overflow-hidden', compact ? 'max-w-[140px]' : 'max-w-[200px]')}>
|
||||||
|
<span className="truncate block" title={filename}>
|
||||||
|
{filename}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{}
|
{}
|
||||||
{imageFiles.length > 0 && (
|
{imageFiles.length > 0 && (
|
||||||
<div className={cn('overflow-x-auto -mx-1 px-1 scrollbar-thin', compact ? 'py-0.5' : 'py-1')}>
|
<div className={cn('overflow-x-auto -mx-1 px-1 scrollbar-thin', compact ? 'py-0.5' : 'py-1')}>
|
||||||
@@ -360,7 +468,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
const filename = extractFilename(file.filename) || 'Image';
|
const filename = extractFilename(file.filename) || 'Image';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip key={`img-${index}`} delayDuration={1000}>
|
<Tooltip key={`img-${file.url || file.filename || index}`} delayDuration={1000}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
|||||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { getStreamdownThemePair } from '@/lib/shiki/appThemeRegistry';
|
import { getStreamdownThemePair } from '@/lib/shiki/appThemeRegistry';
|
||||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||||
|
import type { ToolPopupContent } from './message/types';
|
||||||
|
|
||||||
const withStableStringId = <T extends object>(value: T, id: string): T => {
|
const withStableStringId = <T extends object>(value: T, id: string): T => {
|
||||||
const existingPrimitive = (value as Record<symbol, unknown>)[Symbol.toPrimitive];
|
const existingPrimitive = (value as Record<symbol, unknown>)[Symbol.toPrimitive];
|
||||||
@@ -131,13 +132,17 @@ const useStreamdownMermaidOptions = () => {
|
|||||||
? fallbackDark
|
? fallbackDark
|
||||||
: fallbackLight);
|
: fallbackLight);
|
||||||
|
|
||||||
const mermaidRenderKey = `${currentTheme.metadata.id}:${currentTheme.metadata.variant}:${themeSystem?.themeMode ?? 'fallback'}`;
|
const MERMAID_CONFIG_VERSION = 'sequence-nowrap-v1';
|
||||||
|
const mermaidRenderKey = `${currentTheme.metadata.id}:${currentTheme.metadata.variant}:${themeSystem?.themeMode ?? 'fallback'}:${MERMAID_CONFIG_VERSION}`;
|
||||||
|
|
||||||
const options = React.useMemo(() => {
|
const options = React.useMemo(() => {
|
||||||
const isDark = currentTheme.metadata.variant === 'dark';
|
const isDark = currentTheme.metadata.variant === 'dark';
|
||||||
return {
|
return {
|
||||||
config: {
|
config: {
|
||||||
theme: isDark ? 'dark' : 'base',
|
theme: isDark ? 'dark' : 'base',
|
||||||
|
sequence: {
|
||||||
|
useMaxWidth: false,
|
||||||
|
},
|
||||||
themeVariables: {
|
themeVariables: {
|
||||||
primaryColor: currentTheme.colors.surface.elevated,
|
primaryColor: currentTheme.colors.surface.elevated,
|
||||||
primaryTextColor: currentTheme.colors.surface.foreground,
|
primaryTextColor: currentTheme.colors.surface.foreground,
|
||||||
@@ -475,9 +480,9 @@ const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className
|
|||||||
return next;
|
return next;
|
||||||
}, [style]);
|
}, [style]);
|
||||||
|
|
||||||
// Mermaid blocks get their own controls via MermaidWrapper — skip the code copy button.
|
// Mermaid blocks are handled by Streamdown Mermaid controls.
|
||||||
if (mermaidInfo.isMermaid) {
|
if (mermaidInfo.isMermaid) {
|
||||||
return <MermaidWrapper source={mermaidInfo.source}>{codeChild}</MermaidWrapper>;
|
return codeChild;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCodeContent = (): string => {
|
const getCodeContent = (): string => {
|
||||||
@@ -535,77 +540,32 @@ const streamdownControls = {
|
|||||||
code: false,
|
code: false,
|
||||||
table: false,
|
table: false,
|
||||||
mermaid: {
|
mermaid: {
|
||||||
download: false,
|
download: true,
|
||||||
copy: false,
|
copy: true,
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
panZoom: false,
|
panZoom: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mermaid copy button — copies raw mermaid source
|
type MermaidControlOptions = {
|
||||||
const MermaidCopyButton: React.FC<{ source: string }> = ({ source }) => {
|
download: boolean;
|
||||||
const [copied, setCopied] = React.useState(false);
|
copy: boolean;
|
||||||
|
fullscreen: boolean;
|
||||||
const handleCopy = async () => {
|
panZoom: boolean;
|
||||||
if (!source) return;
|
|
||||||
const result = await copyTextToClipboard(source);
|
|
||||||
if (result.ok) {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} else {
|
|
||||||
console.error('Failed to copy diagram:', result.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
title="Copy diagram source"
|
|
||||||
>
|
|
||||||
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mermaid download button — downloads rendered SVG
|
const extractMermaidBlocks = (markdown: string): string[] => {
|
||||||
const MermaidDownloadButton: React.FC<{ containerRef: React.RefObject<HTMLDivElement | null> }> = ({ containerRef }) => {
|
const blocks: string[] = [];
|
||||||
const handleDownload = () => {
|
const regex = /(?:^|\r?\n)(`{3,}|~{3,})mermaid[^\n\r]*\r?\n([\s\S]*?)\r?\n\1(?=\r?\n|$)/gi;
|
||||||
const svgEl = containerRef.current?.querySelector('svg');
|
let match: RegExpExecArray | null = regex.exec(markdown);
|
||||||
if (!(svgEl instanceof SVGElement)) return;
|
|
||||||
const serializer = new XMLSerializer();
|
|
||||||
let markup = serializer.serializeToString(svgEl);
|
|
||||||
if (!markup.includes('xmlns=')) {
|
|
||||||
markup = markup.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
|
|
||||||
}
|
|
||||||
downloadFile('diagram.svg', markup, 'image/svg+xml');
|
|
||||||
toast.success('Diagram downloaded');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
while (match) {
|
||||||
<button
|
const block = (match[2] ?? '').replace(/\s+$/, '');
|
||||||
onClick={handleDownload}
|
blocks.push(block);
|
||||||
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
match = regex.exec(markdown);
|
||||||
title="Download diagram"
|
}
|
||||||
>
|
|
||||||
<RiDownloadLine className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mermaid wrapper with custom controls (same pattern as TableWrapper)
|
return blocks;
|
||||||
const MermaidWrapper: React.FC<{ children: React.ReactNode; source: string }> = ({ children, source }) => {
|
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="group relative" ref={containerRef}>
|
|
||||||
{children}
|
|
||||||
<div className="absolute top-1 right-2 z-20 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<MermaidDownloadButton containerRef={containerRef} />
|
|
||||||
<MermaidCopyButton source={source} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MarkdownVariant = 'assistant' | 'tool';
|
export type MarkdownVariant = 'assistant' | 'tool';
|
||||||
@@ -618,8 +578,104 @@ interface MarkdownRendererProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
variant?: MarkdownVariant;
|
variant?: MarkdownVariant;
|
||||||
|
onShowPopup?: (content: ToolPopupContent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MERMAID_BLOCK_SELECTOR = '[data-streamdown="mermaid-block"]';
|
||||||
|
|
||||||
|
const useMermaidInlineInteractions = ({
|
||||||
|
containerRef,
|
||||||
|
mermaidBlocks,
|
||||||
|
onShowPopup,
|
||||||
|
allowWheelZoom,
|
||||||
|
}: {
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
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<MarkdownRendererProps> = ({
|
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||||
content,
|
content,
|
||||||
part,
|
part,
|
||||||
@@ -628,7 +684,12 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
className,
|
className,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
variant = 'assistant',
|
variant = 'assistant',
|
||||||
|
onShowPopup,
|
||||||
}) => {
|
}) => {
|
||||||
|
const streamdownContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
|
||||||
|
useMermaidInlineInteractions({ containerRef: streamdownContainerRef, mermaidBlocks, onShowPopup });
|
||||||
|
|
||||||
const shikiThemes = useMarkdownShikiThemes();
|
const shikiThemes = useMarkdownShikiThemes();
|
||||||
const { options: mermaidOptions, mermaidRenderKey } = useStreamdownMermaidOptions();
|
const { options: mermaidOptions, mermaidRenderKey } = useStreamdownMermaidOptions();
|
||||||
const componentKey = React.useMemo(() => {
|
const componentKey = React.useMemo(() => {
|
||||||
@@ -641,7 +702,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
|||||||
: 'streamdown-content';
|
: 'streamdown-content';
|
||||||
|
|
||||||
const markdownContent = (
|
const markdownContent = (
|
||||||
<div className={cn('break-words', className)}>
|
<div className={cn('break-words', className)} ref={streamdownContainerRef}>
|
||||||
<Streamdown
|
<Streamdown
|
||||||
key={`streamdown-${componentKey}-${mermaidRenderKey}`}
|
key={`streamdown-${componentKey}-${mermaidRenderKey}`}
|
||||||
mode={isStreaming ? 'streaming' : 'static'}
|
mode={isStreaming ? 'streaming' : 'static'}
|
||||||
@@ -672,7 +733,19 @@ export const SimpleMarkdownRenderer: React.FC<{
|
|||||||
content: string;
|
content: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
variant?: MarkdownVariant;
|
variant?: MarkdownVariant;
|
||||||
}> = ({ content, className, variant = 'assistant' }) => {
|
onShowPopup?: (content: ToolPopupContent) => void;
|
||||||
|
mermaidControls?: MermaidControlOptions;
|
||||||
|
allowMermaidWheelZoom?: boolean;
|
||||||
|
}> = ({ content, className, variant = 'assistant', onShowPopup, mermaidControls, allowMermaidWheelZoom = false }) => {
|
||||||
|
const streamdownContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
|
||||||
|
useMermaidInlineInteractions({
|
||||||
|
containerRef: streamdownContainerRef,
|
||||||
|
mermaidBlocks,
|
||||||
|
onShowPopup,
|
||||||
|
allowWheelZoom: allowMermaidWheelZoom,
|
||||||
|
});
|
||||||
|
|
||||||
const shikiThemes = useMarkdownShikiThemes();
|
const shikiThemes = useMarkdownShikiThemes();
|
||||||
const { options: mermaidOptions, mermaidRenderKey } = useStreamdownMermaidOptions();
|
const { options: mermaidOptions, mermaidRenderKey } = useStreamdownMermaidOptions();
|
||||||
|
|
||||||
@@ -681,13 +754,16 @@ export const SimpleMarkdownRenderer: React.FC<{
|
|||||||
: 'streamdown-content';
|
: 'streamdown-content';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('break-words', className)}>
|
<div className={cn('break-words', className)} ref={streamdownContainerRef}>
|
||||||
<Streamdown
|
<Streamdown
|
||||||
key={`streamdown-simple-${mermaidRenderKey}`}
|
key={`streamdown-simple-${mermaidRenderKey}`}
|
||||||
mode="static"
|
mode="static"
|
||||||
shikiTheme={shikiThemes}
|
shikiTheme={shikiThemes}
|
||||||
className={streamdownClassName}
|
className={streamdownClassName}
|
||||||
controls={streamdownControls}
|
controls={{
|
||||||
|
...streamdownControls,
|
||||||
|
mermaid: mermaidControls ?? streamdownControls.mermaid,
|
||||||
|
}}
|
||||||
plugins={streamdownPlugins}
|
plugins={streamdownPlugins}
|
||||||
mermaid={mermaidOptions}
|
mermaid={mermaidOptions}
|
||||||
components={streamdownComponents}
|
components={streamdownComponents}
|
||||||
|
|||||||
@@ -940,6 +940,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
syntaxTheme={syntaxTheme}
|
syntaxTheme={syntaxTheme}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
|
onShowPopup={onShowPopup}
|
||||||
hasPrevTool={false}
|
hasPrevTool={false}
|
||||||
hasNextTool={false}
|
hasNextTool={false}
|
||||||
/>
|
/>
|
||||||
@@ -997,6 +998,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
syntaxTheme={syntaxTheme}
|
syntaxTheme={syntaxTheme}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
|
onShowPopup={onShowPopup}
|
||||||
hasPrevTool={connection?.hasPrev ?? false}
|
hasPrevTool={connection?.hasPrev ?? false}
|
||||||
hasNextTool={connection?.hasNext ?? false}
|
hasNextTool={connection?.hasNext ?? false}
|
||||||
/>
|
/>
|
||||||
@@ -1242,7 +1244,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
{showErrorMessage && (
|
{showErrorMessage && (
|
||||||
<FadeInOnReveal key="assistant-error">
|
<FadeInOnReveal key="assistant-error">
|
||||||
<div className="group/assistant-text relative break-words">
|
<div className="group/assistant-text relative break-words">
|
||||||
<SimpleMarkdownRenderer content={errorMessage ?? ''} />
|
<SimpleMarkdownRenderer content={errorMessage ?? ''} onShowPopup={onShowPopup} />
|
||||||
</div>
|
</div>
|
||||||
</FadeInOnReveal>
|
</FadeInOnReveal>
|
||||||
)}
|
)}
|
||||||
@@ -1251,7 +1253,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
<div
|
<div
|
||||||
className="group/assistant-text relative break-words"
|
className="group/assistant-text relative break-words"
|
||||||
>
|
>
|
||||||
<SimpleMarkdownRenderer content={summaryBody} />
|
<SimpleMarkdownRenderer content={summaryBody} onShowPopup={onShowPopup} />
|
||||||
{shouldShowFooter && (
|
{shouldShowFooter && (
|
||||||
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5">
|
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
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 { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
@@ -42,6 +42,9 @@ const getToolIcon = (toolName: string) => {
|
|||||||
if (tool === 'image-preview') {
|
if (tool === 'image-preview') {
|
||||||
return <RiFileImageLine className={iconClass} />;
|
return <RiFileImageLine className={iconClass} />;
|
||||||
}
|
}
|
||||||
|
if (tool === 'mermaid-preview') {
|
||||||
|
return <RiFileList2Line className={iconClass} />;
|
||||||
|
}
|
||||||
if (tool === 'edit' || tool === 'multiedit' || tool === 'apply_patch' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
if (tool === 'edit' || tool === 'multiedit' || tool === 'apply_patch' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
|
||||||
return <RiPencilAiLine className={iconClass} />;
|
return <RiPencilAiLine className={iconClass} />;
|
||||||
}
|
}
|
||||||
@@ -84,7 +87,157 @@ const getToolIcon = (toolName: string) => {
|
|||||||
return <RiToolsLine className={iconClass} />;
|
return <RiToolsLine className={iconClass} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
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<ViewportSize>(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<{
|
const ImagePreviewDialog: React.FC<{
|
||||||
popup: ToolPopupContent;
|
popup: ToolPopupContent;
|
||||||
@@ -112,13 +265,8 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
|
|
||||||
const [currentIndex, setCurrentIndex] = React.useState(0);
|
const [currentIndex, setCurrentIndex] = React.useState(0);
|
||||||
const [imageNaturalSize, setImageNaturalSize] = React.useState<{ width: number; height: number } | null>(null);
|
const [imageNaturalSize, setImageNaturalSize] = React.useState<{ width: number; height: number } | null>(null);
|
||||||
const [isRendered, setIsRendered] = React.useState(popup.open);
|
const { isRendered, isVisible, isTransitioning } = usePreviewOverlayState(popup.open);
|
||||||
const [isVisible, setIsVisible] = React.useState(popup.open);
|
const viewport = usePreviewViewport(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,
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!popup.open || gallery.length === 0) {
|
if (!popup.open || gallery.length === 0) {
|
||||||
@@ -151,46 +299,6 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
setCurrentIndex((prev) => (prev + 1) % gallery.length);
|
setCurrentIndex((prev) => (prev + 1) % gallery.length);
|
||||||
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (!popup.open) {
|
if (!popup.open) {
|
||||||
return;
|
return;
|
||||||
@@ -220,22 +328,6 @@ const ImagePreviewDialog: React.FC<{
|
|||||||
};
|
};
|
||||||
}, [hasMultipleImages, onOpenChange, popup.open, showNext, showPrevious]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
setImageNaturalSize(null);
|
setImageNaturalSize(null);
|
||||||
}, [currentImage?.url]);
|
}, [currentImage?.url]);
|
||||||
@@ -453,6 +545,342 @@ const DialogReadContent: React.FC<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
DialogReadContent.displayName = 'DialogReadContent';
|
DialogReadContent.displayName = 'DialogReadContent';
|
||||||
|
const MermaidPreviewDialog: React.FC<{
|
||||||
|
popup: ToolPopupContent;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
isMobile: boolean;
|
||||||
|
}> = ({ popup, onOpenChange, isMobile }) => {
|
||||||
|
const [source, setSource] = React.useState<string>(popup.mermaid?.source || '');
|
||||||
|
const [status, setStatus] = React.useState<'idle' | 'loading' | 'ready' | 'error'>(popup.mermaid?.source ? 'ready' : 'idle');
|
||||||
|
const [errorMessage, setErrorMessage] = React.useState<string>('');
|
||||||
|
const { isRendered, isVisible, isTransitioning } = usePreviewOverlayState(popup.open);
|
||||||
|
const [diagramAspectRatio, setDiagramAspectRatio] = React.useState<number | null>(null);
|
||||||
|
const viewport = usePreviewViewport(popup.open);
|
||||||
|
const requestIdRef = React.useRef(0);
|
||||||
|
const mermaidPreviewRef = React.useRef<HTMLDivElement | null>(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 = (
|
||||||
|
<div className={cn('fixed inset-0 z-50', popup.open ? 'pointer-events-auto' : 'pointer-events-none')}>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 bg-black/25 backdrop-blur-md',
|
||||||
|
isTransitioning && 'transition-opacity duration-150 ease-out',
|
||||||
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
onMouseDown={() => onOpenChange(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 flex items-center justify-center pointer-events-none',
|
||||||
|
isMobile ? 'p-2.5' : 'p-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-auto flex flex-col gap-2',
|
||||||
|
isTransitioning && 'transition-opacity duration-150 ease-out',
|
||||||
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
style={{ width: `${dialogSize.width}px` }}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-8 w-8 flex items-center justify-center rounded-lg text-muted-foreground/80 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
aria-label="Close diagram preview"
|
||||||
|
>
|
||||||
|
<RiCloseLine className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden"
|
||||||
|
style={{ height: `${dialogSize.height}px` }}
|
||||||
|
>
|
||||||
|
<div className="h-full overflow-hidden">
|
||||||
|
{status === 'loading' && (
|
||||||
|
<div className="h-full min-h-28 flex items-center justify-center gap-2 text-muted-foreground typography-meta">
|
||||||
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||||
|
<span>Loading diagram...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="rounded-xl border border-border/30 bg-muted/20 p-3 space-y-3">
|
||||||
|
<p className="typography-markdown" style={{ color: 'var(--status-error)' }}>
|
||||||
|
{errorMessage || 'Unable to render Mermaid diagram.'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void loadMermaidSource();
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-lg typography-meta border transition-colors hover:bg-[var(--interactive-hover)]"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--interactive-border)',
|
||||||
|
color: 'var(--surface-foreground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'ready' && (
|
||||||
|
<div ref={mermaidPreviewRef} className="h-full">
|
||||||
|
<SimpleMarkdownRenderer
|
||||||
|
content={mermaidMarkdown}
|
||||||
|
variant="tool"
|
||||||
|
allowMermaidWheelZoom
|
||||||
|
className="streamdown-mermaid-fullscreen h-full [&_[data-streamdown='mermaid-block']_button]:hidden"
|
||||||
|
mermaidControls={{
|
||||||
|
download: false,
|
||||||
|
copy: false,
|
||||||
|
fullscreen: false,
|
||||||
|
panZoom: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(content, document.body);
|
||||||
|
};
|
||||||
|
|
||||||
const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
|
const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange, syntaxTheme, isMobile }) => {
|
||||||
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>(isMobile ? 'unified' : 'side-by-side');
|
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>(isMobile ? 'unified' : 'side-by-side');
|
||||||
@@ -461,6 +889,10 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
|||||||
return <ImagePreviewDialog popup={popup} onOpenChange={onOpenChange} isMobile={isMobile} />;
|
return <ImagePreviewDialog popup={popup} onOpenChange={onOpenChange} isMobile={isMobile} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (popup.mermaid) {
|
||||||
|
return <MermaidPreviewDialog popup={popup} onOpenChange={onOpenChange} isMobile={isMobile} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={popup.open} onOpenChange={onOpenChange}>
|
<Dialog open={popup.open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
|||||||
isMobile,
|
isMobile,
|
||||||
expandedTools,
|
expandedTools,
|
||||||
onToggleTool,
|
onToggleTool,
|
||||||
|
onShowPopup,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
diffStats,
|
diffStats,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -252,6 +253,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
|
|||||||
syntaxTheme={syntaxTheme}
|
syntaxTheme={syntaxTheme}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
|
onShowPopup={onShowPopup}
|
||||||
hasPrevTool={connection?.hasPrev ?? false}
|
hasPrevTool={connection?.hasPrev ?? false}
|
||||||
hasNextTool={connection?.hasNext ?? false}
|
hasNextTool={connection?.hasNext ?? false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
|
import type { ToolPopupContent } from '../types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
renderListOutput,
|
renderListOutput,
|
||||||
@@ -38,6 +39,7 @@ interface ToolPartProps {
|
|||||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||||
|
onShowPopup?: (content: ToolPopupContent) => void;
|
||||||
hasPrevTool?: boolean;
|
hasPrevTool?: boolean;
|
||||||
hasNextTool?: boolean;
|
hasNextTool?: boolean;
|
||||||
}
|
}
|
||||||
@@ -456,7 +458,8 @@ const TaskToolSummary: React.FC<{
|
|||||||
hasNextTool: boolean;
|
hasNextTool: boolean;
|
||||||
output?: string;
|
output?: string;
|
||||||
sessionId?: 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 setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
||||||
const displayEntries = React.useMemo(() => {
|
const displayEntries = React.useMemo(() => {
|
||||||
const nonPending = entries.filter((entry) => entry.state?.status !== 'pending');
|
const nonPending = entries.filter((entry) => entry.state?.status !== 'pending');
|
||||||
@@ -555,7 +558,7 @@ const TaskToolSummary: React.FC<{
|
|||||||
{isOutputExpanded ? (
|
{isOutputExpanded ? (
|
||||||
<ToolScrollableSection maxHeightClass="max-h-[50vh]">
|
<ToolScrollableSection maxHeightClass="max-h-[50vh]">
|
||||||
<div className="w-full min-w-0">
|
<div className="w-full min-w-0">
|
||||||
<SimpleMarkdownRenderer content={trimmedOutput} variant="tool" />
|
<SimpleMarkdownRenderer content={trimmedOutput} variant="tool" onShowPopup={onShowPopup} />
|
||||||
</div>
|
</div>
|
||||||
</ToolScrollableSection>
|
</ToolScrollableSection>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -770,6 +773,7 @@ interface ToolExpandedContentProps {
|
|||||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
currentDirectory: string;
|
currentDirectory: string;
|
||||||
|
onShowPopup?: (content: ToolPopupContent) => void;
|
||||||
hasPrevTool: boolean;
|
hasPrevTool: boolean;
|
||||||
hasNextTool: boolean;
|
hasNextTool: boolean;
|
||||||
}
|
}
|
||||||
@@ -780,6 +784,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
syntaxTheme,
|
syntaxTheme,
|
||||||
isMobile,
|
isMobile,
|
||||||
currentDirectory,
|
currentDirectory,
|
||||||
|
onShowPopup,
|
||||||
hasPrevTool,
|
hasPrevTool,
|
||||||
hasNextTool,
|
hasNextTool,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -946,7 +951,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
if (part.tool === 'task' && hasStringOutput) {
|
if (part.tool === 'task' && hasStringOutput) {
|
||||||
return renderScrollableBlock(
|
return renderScrollableBlock(
|
||||||
<div className="w-full min-w-0">
|
<div className="w-full min-w-0">
|
||||||
<SimpleMarkdownRenderer content={outputString} variant="tool" />
|
<SimpleMarkdownRenderer content={outputString} variant="tool" onShowPopup={onShowPopup} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -965,7 +970,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
if (part.tool === 'codesearch' && hasStringOutput) {
|
if (part.tool === 'codesearch' && hasStringOutput) {
|
||||||
return renderScrollableBlock(
|
return renderScrollableBlock(
|
||||||
<div className="w-full min-w-0">
|
<div className="w-full min-w-0">
|
||||||
<SimpleMarkdownRenderer content={outputString} variant="tool" />
|
<SimpleMarkdownRenderer content={outputString} variant="tool" onShowPopup={onShowPopup} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -973,7 +978,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
if (part.tool === 'skill' && hasStringOutput) {
|
if (part.tool === 'skill' && hasStringOutput) {
|
||||||
return renderScrollableBlock(
|
return renderScrollableBlock(
|
||||||
<div className="w-full min-w-0">
|
<div className="w-full min-w-0">
|
||||||
<SimpleMarkdownRenderer content={outputString} variant="tool" />
|
<SimpleMarkdownRenderer content={outputString} variant="tool" onShowPopup={onShowPopup} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1106,7 +1111,17 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
|
|
||||||
ToolExpandedContent.displayName = 'ToolExpandedContent';
|
ToolExpandedContent.displayName = 'ToolExpandedContent';
|
||||||
|
|
||||||
const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxTheme, isMobile, onContentChange, hasPrevTool = false, hasNextTool = false }) => {
|
const ToolPart: React.FC<ToolPartProps> = ({
|
||||||
|
part,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
syntaxTheme,
|
||||||
|
isMobile,
|
||||||
|
onContentChange,
|
||||||
|
onShowPopup,
|
||||||
|
hasPrevTool = false,
|
||||||
|
hasNextTool = false,
|
||||||
|
}) => {
|
||||||
const state = part.state;
|
const state = part.state;
|
||||||
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||||
|
|
||||||
@@ -1413,6 +1428,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
|||||||
hasNextTool={hasNextTool}
|
hasNextTool={hasNextTool}
|
||||||
output={taskOutputString}
|
output={taskOutputString}
|
||||||
sessionId={taskSessionId}
|
sessionId={taskSessionId}
|
||||||
|
onShowPopup={onShowPopup}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -1423,6 +1439,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
|||||||
syntaxTheme={syntaxTheme}
|
syntaxTheme={syntaxTheme}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
currentDirectory={currentDirectory}
|
currentDirectory={currentDirectory}
|
||||||
|
onShowPopup={onShowPopup}
|
||||||
hasPrevTool={hasPrevTool}
|
hasPrevTool={hasPrevTool}
|
||||||
hasNextTool={hasNextTool}
|
hasNextTool={hasNextTool}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -28,4 +28,10 @@ export interface ToolPopupContent {
|
|||||||
}>;
|
}>;
|
||||||
index?: number;
|
index?: number;
|
||||||
};
|
};
|
||||||
|
mermaid?: {
|
||||||
|
url: string;
|
||||||
|
mimeType?: string;
|
||||||
|
filename?: string;
|
||||||
|
source?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -867,6 +867,69 @@ html:not(.dark) .chat-scroll {
|
|||||||
/* Mermaid block: position context for controls wrapper. */
|
/* Mermaid block: position context for controls wrapper. */
|
||||||
[data-streamdown="mermaid-block"] {
|
[data-streamdown="mermaid-block"] {
|
||||||
position: relative;
|
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 */
|
/* Hide system caret in terminal - targets both Ghostty internal input and our custom touch input */
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ class OpencodeService {
|
|||||||
'application/x-sh',
|
'application/x-sh',
|
||||||
'application/x-shellscript',
|
'application/x-shellscript',
|
||||||
'application/octet-stream',
|
'application/octet-stream',
|
||||||
|
'image/svg+xml',
|
||||||
];
|
];
|
||||||
|
|
||||||
return textBasedTypes.includes(lowerMime);
|
return textBasedTypes.includes(lowerMime);
|
||||||
|
|||||||
@@ -10432,17 +10432,26 @@ Context:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resolvedPath = path.resolve(normalizeDirectoryPath(filePath));
|
const resolved = await resolveWorkspacePathFromContext(req, filePath);
|
||||||
if (resolvedPath.includes('..')) {
|
if (!resolved.ok) {
|
||||||
return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
|
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()) {
|
if (!stats.isFile()) {
|
||||||
return res.status(400).json({ error: 'Specified path is not a file' });
|
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);
|
res.type('text/plain').send(content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error;
|
const err = error;
|
||||||
@@ -10465,17 +10474,26 @@ Context:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resolvedPath = path.resolve(normalizeDirectoryPath(filePath));
|
const resolved = await resolveWorkspacePathFromContext(req, filePath);
|
||||||
if (resolvedPath.includes('..')) {
|
if (!resolved.ok) {
|
||||||
return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
|
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()) {
|
if (!stats.isFile()) {
|
||||||
return res.status(400).json({ error: 'Specified path is not a file' });
|
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 = {
|
const mimeMap = {
|
||||||
'.png': 'image/png',
|
'.png': 'image/png',
|
||||||
'.jpg': 'image/jpeg',
|
'.jpg': 'image/jpeg',
|
||||||
@@ -10489,7 +10507,7 @@ Context:
|
|||||||
};
|
};
|
||||||
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
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.setHeader('Cache-Control', 'no-store');
|
||||||
res.type(mimeType).send(content);
|
res.type(mimeType).send(content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user