feat(chat): add Mermaid diagram zoom controls (#2100)
* feat(chat): add mermaid diagram zoom controls * fix(chat): handle malformed mermaid data urls * fix(chat): preserve mermaid load error stack --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
dfed121bf1
commit
3d32ac7989
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
|
||||
|
||||
describe('getMermaidDataUrlSourcePromise', () => {
|
||||
test('turns malformed data URLs into rejected promises', async () => {
|
||||
const sourcePromise = getMermaidDataUrlSourcePromise('data:text/plain;base64');
|
||||
|
||||
await sourcePromise.then(
|
||||
() => {
|
||||
throw new Error('expected malformed data URL to reject');
|
||||
},
|
||||
(error) => {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(MermaidLoadFailure);
|
||||
expect(error.key).toBe('chat.toolOutputDialog.mermaid.dataUrlMalformed');
|
||||
expect(error.params).toBe(undefined);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mermaid load request ids', () => {
|
||||
test('invalidates stale async loads when a newer load starts', () => {
|
||||
const firstRequest = nextMermaidLoadRequestId(0);
|
||||
const secondRequest = nextMermaidLoadRequestId(firstRequest);
|
||||
|
||||
expect(isCurrentMermaidLoadRequest(secondRequest, firstRequest)).toBe(false);
|
||||
expect(isCurrentMermaidLoadRequest(secondRequest, secondRequest)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -26,8 +26,9 @@ import { DiffViewToggle } from './DiffViewToggle';
|
||||
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
|
||||
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useI18n, type I18nKey, type I18nParams } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, isMermaidLoadFailure, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
|
||||
|
||||
interface ToolOutputDialogProps {
|
||||
popup: ToolPopupContent;
|
||||
@@ -35,6 +36,8 @@ interface ToolOutputDialogProps {
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
const mermaidLoadFailure = (key: I18nKey, params?: I18nParams): MermaidLoadFailure => new MermaidLoadFailure(key, params);
|
||||
|
||||
const getToolIcon = (toolName: string) => {
|
||||
const iconClass = 'h-3.5 w-3.5 flex-shrink-0';
|
||||
const tool = toolName.toLowerCase();
|
||||
@@ -97,7 +100,7 @@ const MERMAID_ASPECT_MAX_RETRIES = 3;
|
||||
|
||||
const DIALOG_CODE_TAG_PROPS = { style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } };
|
||||
|
||||
const MERMAID_CONTROLS = { download: false, copy: false, fullscreen: false, panZoom: true };
|
||||
const MERMAID_CONTROLS = { download: false, copy: false, showPanZoomControls: true };
|
||||
|
||||
type PierreThemeConfig = {
|
||||
theme: { light: string; dark: string };
|
||||
@@ -694,22 +697,11 @@ const MermaidPreviewDialog: React.FC<{
|
||||
return isSafeLocalPath(decoded) ? decoded : (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;
|
||||
const requestId = nextMermaidLoadRequestId(requestIdRef.current);
|
||||
requestIdRef.current = requestId;
|
||||
|
||||
if (!target?.url) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('chat.toolOutputDialog.mermaid.missingSource'));
|
||||
@@ -723,24 +715,21 @@ const MermaidPreviewDialog: React.FC<{
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
|
||||
setStatus('loading');
|
||||
setErrorMessage('');
|
||||
|
||||
let sourcePromise: Promise<string>;
|
||||
if (target.url.startsWith('data:')) {
|
||||
sourcePromise = Promise.resolve(decodeDataUrl(target.url));
|
||||
sourcePromise = getMermaidDataUrlSourcePromise(target.url);
|
||||
} else if (target.url.toLowerCase().startsWith('file://')) {
|
||||
const normalizedPath = normalizeFilePath(target.url);
|
||||
if (!normalizedPath) {
|
||||
sourcePromise = Promise.reject(new Error('Invalid local file path for Mermaid preview.'));
|
||||
sourcePromise = Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.invalidLocalPath'));
|
||||
} else {
|
||||
sourcePromise = runtimeFetch('/api/fs/raw', { query: { path: normalizedPath } })
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(new Error(`Failed to read diagram file (${response.status})`));
|
||||
return Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.readFileFailedWithStatus', { status: response.status }));
|
||||
}
|
||||
return response.text();
|
||||
});
|
||||
@@ -752,12 +741,12 @@ const MermaidPreviewDialog: React.FC<{
|
||||
const resolvedUrl = canParse ? new URL(target.url, window.location.origin) : null;
|
||||
|
||||
if (!resolvedUrl || (resolvedUrl.protocol !== 'http:' && resolvedUrl.protocol !== 'https:')) {
|
||||
sourcePromise = Promise.reject(new Error('Unsupported Mermaid URL protocol.'));
|
||||
sourcePromise = Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.unsupportedUrlProtocol'));
|
||||
} else {
|
||||
sourcePromise = fetch(resolvedUrl.toString())
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return Promise.reject(new Error(`Failed to load diagram (${response.status})`));
|
||||
return Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.loadFailedWithStatus', { status: response.status }));
|
||||
}
|
||||
return response.text();
|
||||
});
|
||||
@@ -766,7 +755,7 @@ const MermaidPreviewDialog: React.FC<{
|
||||
|
||||
await sourcePromise
|
||||
.then((resolvedSource) => {
|
||||
if (requestIdRef.current !== requestId) {
|
||||
if (!isCurrentMermaidLoadRequest(requestIdRef.current, requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -774,13 +763,13 @@ const MermaidPreviewDialog: React.FC<{
|
||||
setStatus('ready');
|
||||
})
|
||||
.catch((error) => {
|
||||
if (requestIdRef.current !== requestId) {
|
||||
if (!isCurrentMermaidLoadRequest(requestIdRef.current, requestId)) {
|
||||
return;
|
||||
}
|
||||
setStatus('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : t('chat.toolOutputDialog.mermaid.loadFailed'));
|
||||
setErrorMessage(isMermaidLoadFailure(error) ? t(error.key, error.params) : t('chat.toolOutputDialog.mermaid.loadFailed'));
|
||||
});
|
||||
}, [decodeDataUrl, normalizeFilePath, popup.mermaid, t]);
|
||||
}, [normalizeFilePath, popup.mermaid, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!popup.open || !popup.mermaid) {
|
||||
@@ -896,10 +885,11 @@ const MermaidPreviewDialog: React.FC<{
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute inset-0 bg-black/40',
|
||||
'absolute inset-0',
|
||||
isTransitioning && 'transition-opacity duration-150 ease-out',
|
||||
isVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-background) 70%, transparent)' }}
|
||||
onMouseDown={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
@@ -941,7 +931,13 @@ const MermaidPreviewDialog: React.FC<{
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="rounded-xl border border-border/30 bg-muted/20 p-3 space-y-3">
|
||||
<div
|
||||
className="rounded-xl border p-3 space-y-3"
|
||||
style={{
|
||||
backgroundColor: 'var(--status-error-background)',
|
||||
borderColor: 'var(--status-error-border)',
|
||||
}}
|
||||
>
|
||||
<p className="typography-markdown" style={{ color: 'var(--status-error)' }}>
|
||||
{errorMessage || t('chat.toolOutputDialog.mermaid.renderFailed')}
|
||||
</p>
|
||||
@@ -966,8 +962,8 @@ const MermaidPreviewDialog: React.FC<{
|
||||
<SimpleMarkdownRenderer
|
||||
content={mermaidMarkdown}
|
||||
variant="tool"
|
||||
allowMermaidWheelZoom
|
||||
className="markdown-mermaid-fullscreen h-full [&_[data-markdown='mermaid-block']_button]:hidden"
|
||||
allowMermaidWheelEvents
|
||||
className="markdown-mermaid-fullscreen h-full"
|
||||
mermaidControls={MERMAID_CONTROLS}
|
||||
enableFileReferences={false}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { I18nKey, I18nParams } from '@/lib/i18n';
|
||||
|
||||
export class MermaidLoadFailure extends Error {
|
||||
key: I18nKey;
|
||||
params?: I18nParams;
|
||||
|
||||
constructor(key: I18nKey, params?: I18nParams) {
|
||||
super(key);
|
||||
this.name = 'MermaidLoadFailure';
|
||||
this.key = key;
|
||||
this.params = params;
|
||||
}
|
||||
}
|
||||
|
||||
const mermaidLoadFailure = (key: I18nKey, params?: I18nParams): MermaidLoadFailure => new MermaidLoadFailure(key, params);
|
||||
|
||||
export const isMermaidLoadFailure = (value: unknown): value is MermaidLoadFailure => value instanceof MermaidLoadFailure;
|
||||
|
||||
export const nextMermaidLoadRequestId = (current: number): number => current + 1;
|
||||
|
||||
export const isCurrentMermaidLoadRequest = (current: number, requestId: number): boolean => current === requestId;
|
||||
|
||||
const decodeMermaidDataUrl = (value: string): string => {
|
||||
const commaIndex = value.indexOf(',');
|
||||
if (commaIndex < 0) {
|
||||
throw mermaidLoadFailure('chat.toolOutputDialog.mermaid.dataUrlMalformed');
|
||||
}
|
||||
|
||||
const metadata = value.slice(0, commaIndex).toLowerCase();
|
||||
const payload = value.slice(commaIndex + 1);
|
||||
if (metadata.includes(';base64')) {
|
||||
return atob(payload);
|
||||
}
|
||||
return decodeURIComponent(payload);
|
||||
};
|
||||
|
||||
export const getMermaidDataUrlSourcePromise = (value: string): Promise<string> => Promise.resolve().then(() => decodeMermaidDataUrl(value));
|
||||
Reference in New Issue
Block a user