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:
Iuliia Ivashko
2026-02-24 01:49:06 +02:00
committed by GitHub
parent b2101acfbf
commit d2d39c48ac
11 changed files with 882 additions and 157 deletions
@@ -69,6 +69,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
isMobile,
expandedTools,
onToggleTool,
onShowPopup,
onContentChange,
diffStats,
}) => {
@@ -252,6 +253,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
syntaxTheme={syntaxTheme}
isMobile={isMobile}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
hasPrevTool={connection?.hasPrev ?? false}
hasNextTool={connection?.hasNext ?? false}
/>
@@ -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 ? (
<ToolScrollableSection maxHeightClass="max-h-[50vh]">
<div className="w-full min-w-0">
<SimpleMarkdownRenderer content={trimmedOutput} variant="tool" />
<SimpleMarkdownRenderer content={trimmedOutput} variant="tool" onShowPopup={onShowPopup} />
</div>
</ToolScrollableSection>
) : 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<ToolExpandedContentProps> = React.memo(({
syntaxTheme,
isMobile,
currentDirectory,
onShowPopup,
hasPrevTool,
hasNextTool,
}) => {
@@ -946,7 +951,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
if (part.tool === 'task' && hasStringOutput) {
return renderScrollableBlock(
<div className="w-full min-w-0">
<SimpleMarkdownRenderer content={outputString} variant="tool" />
<SimpleMarkdownRenderer content={outputString} variant="tool" onShowPopup={onShowPopup} />
</div>
);
}
@@ -965,7 +970,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
if (part.tool === 'codesearch' && hasStringOutput) {
return renderScrollableBlock(
<div className="w-full min-w-0">
<SimpleMarkdownRenderer content={outputString} variant="tool" />
<SimpleMarkdownRenderer content={outputString} variant="tool" onShowPopup={onShowPopup} />
</div>
);
}
@@ -973,7 +978,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
if (part.tool === 'skill' && hasStringOutput) {
return renderScrollableBlock(
<div className="w-full min-w-0">
<SimpleMarkdownRenderer content={outputString} variant="tool" />
<SimpleMarkdownRenderer content={outputString} variant="tool" onShowPopup={onShowPopup} />
</div>
);
}
@@ -1106,7 +1111,17 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
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 currentDirectory = useDirectoryStore((s) => s.currentDirectory);
@@ -1413,6 +1428,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
hasNextTool={hasNextTool}
output={taskOutputString}
sessionId={taskSessionId}
onShowPopup={onShowPopup}
/>
) : null}
@@ -1423,6 +1439,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
syntaxTheme={syntaxTheme}
isMobile={isMobile}
currentDirectory={currentDirectory}
onShowPopup={onShowPopup}
hasPrevTool={hasPrevTool}
hasNextTool={hasNextTool}
/>