diff --git a/__mocks__/rehype-raw.js b/__mocks__/rehype-raw.js
new file mode 100644
index 0000000..74a2d3a
--- /dev/null
+++ b/__mocks__/rehype-raw.js
@@ -0,0 +1,16 @@
+/**
+ * Mock for rehype-raw plugin
+ *
+ * rehype-raw is an ES module that causes issues with Jest's CommonJS environment.
+ * This mock provides a no-op transformer that passes the syntax tree through unchanged.
+ *
+ * In tests, we don't need actual HTML parsing since we're testing component behavior,
+ * not the markdown rendering itself.
+ */
+module.exports = function rehypeRaw() {
+ return function transformer(tree) {
+ return tree;
+ };
+};
+
+module.exports.default = module.exports;
diff --git a/__mocks__/remark-gfm.js b/__mocks__/remark-gfm.js
new file mode 100644
index 0000000..a2acd55
--- /dev/null
+++ b/__mocks__/remark-gfm.js
@@ -0,0 +1,16 @@
+/**
+ * Mock for remark-gfm (GitHub Flavored Markdown) plugin
+ *
+ * remark-gfm is an ES module that causes issues with Jest's CommonJS environment.
+ * This mock provides a no-op transformer that passes the syntax tree through unchanged.
+ *
+ * In tests, we don't need actual GFM parsing (tables, strikethrough, etc.) since
+ * we're testing component behavior, not markdown rendering.
+ */
+module.exports = function remarkGfm() {
+ return function transformer(tree) {
+ return tree;
+ };
+};
+
+module.exports.default = module.exports;
diff --git a/__mocks__/remark-math.js b/__mocks__/remark-math.js
new file mode 100644
index 0000000..60b4f87
--- /dev/null
+++ b/__mocks__/remark-math.js
@@ -0,0 +1,16 @@
+/**
+ * Mock for remark-math plugin
+ *
+ * remark-math is an ES module that causes issues with Jest's CommonJS environment.
+ * This mock provides a no-op transformer that passes the syntax tree through unchanged.
+ *
+ * In tests, we don't need actual LaTeX math parsing since we're testing component
+ * behavior, not mathematical equation rendering.
+ */
+module.exports = function remarkMath() {
+ return function transformer(tree) {
+ return tree;
+ };
+};
+
+module.exports.default = module.exports;
diff --git a/__tests__/components/Chat/ChatMessage.test.tsx b/__tests__/components/Chat/ChatMessage.test.tsx
new file mode 100644
index 0000000..27c76c3
--- /dev/null
+++ b/__tests__/components/Chat/ChatMessage.test.tsx
@@ -0,0 +1,112 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { ChatMessage } from '@/components/Chat/ChatMessage';
+import HomeContext from '@/pages/api/home/home.context';
+import { Message } from '@/types/chat';
+
+// Mock the HtmlFileRenderer to check if it's being rendered
+jest.mock('@/components/Chat/HtmlFileRenderer', () => ({
+ HtmlFileRenderer: jest.fn(() =>
),
+}));
+
+// Mock the Avatar component
+jest.mock('@/components/Avatar/BotAvatar', () => ({
+ BotAvatar: jest.fn(() => ),
+}));
+
+describe('ChatMessage', () => {
+ const mockHomeContextValue = {
+ state: {
+ selectedConversation: {
+ messages: [],
+ },
+ conversations: [],
+ messageIsStreaming: false,
+ },
+ dispatch: jest.fn(),
+ };
+
+ const renderWithMessage = (message: Message) => {
+ return render(
+
+
+
+ );
+ };
+
+ it('should render HtmlFileRenderer for an assistant message with an HTML file link', () => {
+ const message: Message = {
+ id: "1",
+ role: 'assistant',
+ content: 'Here is your plot: Plot',
+ };
+ renderWithMessage(message);
+
+ expect(screen.getByTestId('html-file-renderer')).toBeInTheDocument();
+ });
+
+ it('should remove the HTML file link from the displayed message content', () => {
+ const message: Message = {
+ id: "1",
+ role: 'assistant',
+ content: 'Here is your plot: Plot',
+ };
+ renderWithMessage(message);
+
+ // Verify the link is removed but surrounding text is preserved
+ expect(screen.getByText(/Here is your plot/i)).toBeInTheDocument();
+ // The HtmlFileRenderer should be shown instead of the raw link
+ expect(screen.getByTestId('html-file-renderer')).toBeInTheDocument();
+ });
+
+ it('should not render HtmlFileRenderer for a user message, even with a link', () => {
+ const message: Message = {
+ id: "1",
+ role: 'user',
+ content: 'Can you show me a plot from Plot?',
+ };
+ renderWithMessage(message);
+
+ expect(screen.queryByTestId('html-file-renderer')).not.toBeInTheDocument();
+ // Check that the content is rendered as is for the user message
+ expect(screen.getByText(/Can you show me a plot from/)).toBeInTheDocument();
+ });
+
+ it('should not render HtmlFileRenderer for an assistant message without a link', () => {
+ const message: Message = {
+ id: "1",
+ role: 'assistant',
+ content: 'Hello! How can I help you today?',
+ };
+ renderWithMessage(message);
+
+ expect(screen.queryByTestId('html-file-renderer')).not.toBeInTheDocument();
+ expect(screen.getByText('Hello! How can I help you today?')).toBeInTheDocument();
+ });
+
+ it('should render HtmlFileRenderer for inline HTML content', () => {
+ const message: Message = {
+ id: "1",
+ role: 'assistant',
+ content: 'Here is an inline plot:
My Plot
',
+ };
+ renderWithMessage(message);
+
+ expect(screen.getByTestId('html-file-renderer')).toBeInTheDocument();
+ expect(screen.getByText(/Here is an inline plot/i)).toBeInTheDocument();
+ });
+
+ it('should handle multiple HTML files in one message', () => {
+ const message: Message = {
+ id: "1",
+ role: 'assistant',
+ content: 'First: Plot 1 and second: [Plot 2](file:///plot2.html)',
+ };
+ renderWithMessage(message);
+
+ // Should render multiple HtmlFileRenderer components (mocked)
+ const renderers = screen.getAllByTestId('html-file-renderer');
+ expect(renderers.length).toBeGreaterThanOrEqual(2);
+ });
+});
diff --git a/__tests__/components/Chat/HtmlFileRenderer.test.tsx b/__tests__/components/Chat/HtmlFileRenderer.test.tsx
new file mode 100644
index 0000000..3fc9e0f
--- /dev/null
+++ b/__tests__/components/Chat/HtmlFileRenderer.test.tsx
@@ -0,0 +1,147 @@
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { HtmlFileRenderer } from '@/components/Chat/HtmlFileRenderer';
+
+// Mock fetch
+global.fetch = jest.fn();
+
+// Mock navigator.clipboard
+Object.defineProperty(navigator, 'clipboard', {
+ value: {
+ writeText: jest.fn().mockResolvedValue(undefined),
+ },
+ writable: true,
+});
+
+describe('HtmlFileRenderer', () => {
+ beforeEach(() => {
+ (fetch as jest.Mock).mockClear();
+ (navigator.clipboard.writeText as jest.Mock).mockClear();
+ });
+
+ it('should render loading state initially, then render the plot when file exists', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce({
+ ok: true, // For checkFileExists
+ });
+ (fetch as jest.Mock).mockResolvedValueOnce({
+ ok: true,
+ text: () => Promise.resolve('
Test Plot
'), // For loadHtmlContent
+ });
+
+ render();
+
+ // It should initially show a loading skeleton, but that's hard to test without more specific selectors.
+ // We'll wait for the content to appear.
+
+ await waitFor(() => {
+ expect(screen.getByTitle('My Plot')).toBeInTheDocument();
+ });
+
+ const iframe = screen.getByTitle('My Plot');
+ expect(iframe).toHaveAttribute('srcDoc', '
Test Plot
');
+ });
+
+ it('should render nothing if the file does not exist', async () => {
+ (fetch as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 404,
+ });
+
+ const { container } = render();
+
+ await waitFor(() => {
+ expect(container.firstChild).toBeNull();
+ });
+ });
+
+ it('should display an error message if fetching content fails', async () => {
+ (fetch as jest.Mock).mockResolvedValueOnce({ ok: true }); // file exists check
+ (fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error')); // content fetch fails
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/Could not load plot inline/i)).toBeInTheDocument();
+ expect(screen.getByText(/Failed to load HTML file: Network error/i)).toBeInTheDocument();
+ });
+ });
+
+ it('should render inline HTML content directly without fetching', async () => {
+ const inlineContent = '
{!messageIsStreaming && (
<>
diff --git a/components/Chat/HtmlFileRenderer.tsx b/components/Chat/HtmlFileRenderer.tsx
new file mode 100644
index 0000000..744cf00
--- /dev/null
+++ b/components/Chat/HtmlFileRenderer.tsx
@@ -0,0 +1,321 @@
+'use client';
+import React, { useState, useEffect } from 'react';
+import { IconEye, IconEyeOff, IconExternalLink, IconFile, IconDownload } from '@tabler/icons-react';
+
+interface HtmlFileRendererProps {
+ filePath: string;
+ title?: string;
+ isInlineHtml?: boolean;
+ htmlContent?: string;
+}
+
+export const HtmlFileRenderer: React.FC = ({
+ filePath,
+ title,
+ isInlineHtml = false,
+ htmlContent: inlineHtmlContent
+}) => {
+ const [isExpanded, setIsExpanded] = useState(true);
+ const [htmlContent, setHtmlContent] = useState(inlineHtmlContent || '');
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [fileExists, setFileExists] = useState(null);
+
+ const cleanFilePath = (path: string): string => {
+ // For inline HTML, return a descriptive name
+ if (isInlineHtml) {
+ return title || 'Inline HTML Content';
+ }
+
+ // Remove any malformed prefixes or HTML artifacts
+ let cleaned = path.replace(/^.*?href=["']?/, '');
+ cleaned = cleaned.replace(/["'>].*$/, '');
+
+ // Remove file:// prefix for API call
+ cleaned = cleaned.replace('file://', '');
+
+ return cleaned;
+ };
+
+ const checkFileExists = async () => {
+ // For inline HTML, file always "exists"
+ if (isInlineHtml && inlineHtmlContent) {
+ setFileExists(true);
+ return;
+ }
+
+ try {
+ const cleanPath = cleanFilePath(filePath);
+ console.log('Checking if HTML file exists:', cleanPath);
+
+ // Use a simple GET request to check if file exists
+ // We'll abort quickly to avoid downloading large files
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout
+
+ const response = await fetch(`/api/load-html-file?path=${encodeURIComponent(cleanPath)}`, {
+ signal: controller.signal
+ });
+
+ clearTimeout(timeoutId);
+
+ if (response.ok) {
+ setFileExists(true);
+ } else if (response.status === 404) {
+ console.log(`File does not exist: ${cleanPath} (404 Not Found)`);
+ setFileExists(false);
+ } else {
+ console.log(`File check failed: ${cleanPath} (${response.status})`);
+ setFileExists(false);
+ }
+ } catch (err: any) {
+ // If it's an abort error, still check - might just be slow
+ if (err.name === 'AbortError') {
+ console.log(`File check timed out, assuming file exists: ${filePath}`);
+ setFileExists(true);
+ } else {
+ console.log(`Error checking file existence: ${err.message}`);
+ setFileExists(false);
+ }
+ }
+ };
+
+ const loadHtmlContent = async () => {
+ // If it's inline HTML, content is already provided
+ if (isInlineHtml && inlineHtmlContent) {
+ setHtmlContent(inlineHtmlContent);
+ return;
+ }
+
+ if (isExpanded && !htmlContent && !error && fileExists) {
+ setIsLoading(true);
+ setError('');
+
+ try {
+ const cleanPath = cleanFilePath(filePath);
+ console.log('Loading HTML file via API:', cleanPath);
+
+ const response = await fetch(`/api/load-html-file?path=${encodeURIComponent(cleanPath)}`);
+
+ if (!response.ok) {
+ throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
+ }
+
+ const content = await response.text();
+ setHtmlContent(content);
+ } catch (err: any) {
+ console.error('Error loading HTML file:', err);
+ setError(`Failed to load HTML file: ${err.message}`);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ useEffect(() => {
+ // First check if file exists
+ checkFileExists();
+ }, [filePath, isInlineHtml, inlineHtmlContent]);
+
+ useEffect(() => {
+ // Only load content if file exists and component is expanded
+ if (isExpanded && fileExists) {
+ loadHtmlContent();
+ }
+ }, [isExpanded, fileExists]);
+
+ // Don't render the component if file doesn't exist
+ if (fileExists === false) {
+ return null;
+ }
+
+ // Show loading state while checking file existence
+ if (fileExists === null) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const openInSystemBrowser = () => {
+ if (isInlineHtml) {
+ // For inline HTML, create a blob URL and try to open it
+ try {
+ const blob = new Blob([inlineHtmlContent || ''], { type: 'text/html' });
+ const url = URL.createObjectURL(blob);
+ window.open(url, '_blank');
+ // Clean up the URL after a delay
+ setTimeout(() => URL.revokeObjectURL(url), 10000);
+ } catch (error) {
+ console.error('Error opening inline HTML:', error);
+ alert('Unable to open inline HTML content in new window.');
+ }
+ return;
+ }
+
+ const cleanPath = cleanFilePath(filePath);
+ // Try to open in system file manager/browser
+ try {
+ // For desktop apps or Electron, this might work
+ if ((window as any).electronAPI) {
+ (window as any).electronAPI.openFile(cleanPath);
+ } else {
+ // Provide instructions to user
+ alert(`To view this plot, please open the following file in your browser:\n\n${cleanPath}\n\nYou can copy this path and paste it into your browser's address bar.`);
+ }
+ } catch (error) {
+ console.error('Error opening file:', error);
+ alert(`To view this plot, please open the following file in your browser:\n\n${cleanPath}`);
+ }
+ };
+
+ const copyPathToClipboard = async () => {
+ try {
+ if (isInlineHtml) {
+ // For inline HTML, copy the HTML content itself
+ await navigator.clipboard.writeText(inlineHtmlContent || '');
+ alert('HTML content copied to clipboard!');
+ } else {
+ const cleanPath = cleanFilePath(filePath);
+ await navigator.clipboard.writeText(cleanPath);
+ alert('File path copied to clipboard! Paste it into your browser address bar to view the plot.');
+ }
+ } catch (error) {
+ console.error('Failed to copy to clipboard:', error);
+ if (isInlineHtml) {
+ alert('Failed to copy HTML content to clipboard.');
+ } else {
+ const cleanPath = cleanFilePath(filePath);
+ alert(`Copy this path to your browser:\n\n${cleanPath}`);
+ }
+ }
+ };
+
+ const displayPath = cleanFilePath(filePath);
+
+ return (
+