diff --git a/src/components/chat/view/subcomponents/LoadAllMessagesOverlay.tsx b/src/components/chat/view/subcomponents/LoadAllMessagesOverlay.tsx
new file mode 100644
index 00000000..ef246756
--- /dev/null
+++ b/src/components/chat/view/subcomponents/LoadAllMessagesOverlay.tsx
@@ -0,0 +1,68 @@
+import { useTranslation } from 'react-i18next';
+
+const loadAllOverlayAnimationStyle = `
+@keyframes loadAllOverlayAutoFade {
+ 0%, 80% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .load-all-overlay-auto-fade {
+ animation: none !important;
+ }
+}
+`;
+
+interface LoadAllMessagesOverlayProps {
+ showLoadAllOverlay: boolean;
+ isLoadingAllMessages: boolean;
+ loadAllJustFinished: boolean;
+ totalMessages: number;
+ onLoadAllMessages: () => void;
+}
+
+export default function LoadAllMessagesOverlay({
+ showLoadAllOverlay,
+ isLoadingAllMessages,
+ loadAllJustFinished,
+ totalMessages,
+ onLoadAllMessages,
+}: LoadAllMessagesOverlayProps) {
+ const { t } = useTranslation('chat');
+
+ if (!showLoadAllOverlay && !isLoadingAllMessages && !loadAllJustFinished) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/chat/view/subcomponents/Markdown.tsx b/src/components/chat/view/subcomponents/Markdown.tsx
index fc1b9f19..4bacad2c 100644
--- a/src/components/chat/view/subcomponents/Markdown.tsx
+++ b/src/components/chat/view/subcomponents/Markdown.tsx
@@ -4,11 +4,12 @@ import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
-import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
+import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTranslation } from 'react-i18next';
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
import { copyTextToClipboard } from '../../../../utils/clipboard';
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
+import { useTheme } from '../../../../contexts/ThemeContext';
type MarkdownProps = {
children: React.ReactNode;
@@ -59,6 +60,7 @@ type CodeBlockProps = {
const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
const { t } = useTranslation('chat');
+ const { isDarkMode } = useTheme();
const [copied, setCopied] = useState(false);
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(raw);
@@ -96,7 +98,7 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
}
})
}
- className="absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 focus:opacity-100 active:opacity-100 group-hover:opacity-100"
+ className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 px-2 py-1 text-xs text-foreground/80 opacity-0 transition-opacity hover:bg-muted focus:opacity-100 active:opacity-100 group-hover:opacity-100"
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
>
@@ -132,17 +134,20 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
@@ -154,6 +159,10 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
const markdownComponents = {
code: CodeBlock,
+ // CodeBlock renders its own syntax-highlighted ; this passthrough stops
+ // react-markdown (and Tailwind Typography) from wrapping it in a second,
+ // dark-themed shell that would frame the block.
+ pre: ({ children }: { children?: React.ReactNode }) => <>{children}>,
blockquote: ({ children }: { children?: React.ReactNode }) => (
{children}
diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx
index e9615a85..b486da61 100644
--- a/src/components/chat/view/subcomponents/MessageComponent.tsx
+++ b/src/components/chat/view/subcomponents/MessageComponent.tsx
@@ -1,4 +1,4 @@
-import { memo, useEffect, useMemo, useRef, useState } from 'react';
+import { memo, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
@@ -30,7 +30,6 @@ type MessageComponentProps = {
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
- autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject?: Project | null;
@@ -45,7 +44,7 @@ type InteractiveOption = {
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
-const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
+const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') ||
@@ -53,7 +52,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
(prevMessage.type === 'tool') ||
(prevMessage.type === 'error'));
const messageRef = useRef(null);
- const [isExpanded, setIsExpanded] = useState(false);
const userCopyContent = String(message.content || '');
const formattedMessageContent = useMemo(
() => formatUsageLimitText(String(message.content || '')),
@@ -72,32 +70,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
!message.isThinking;
- useEffect(() => {
- const node = messageRef.current;
- if (!autoExpandTools || !node || !message.isToolUse) return;
-
- const observer = new IntersectionObserver(
- (entries) => {
- entries.forEach((entry) => {
- if (entry.isIntersecting && !isExpanded) {
- setIsExpanded(true);
- const details = node.querySelectorAll('details');
- details.forEach((detail) => {
- detail.open = true;
- });
- }
- });
- },
- { threshold: 0.1 }
- );
-
- observer.observe(node);
-
- return () => {
- observer.unobserve(node);
- };
- }, [autoExpandTools, isExpanded, message.isToolUse]);
-
const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);
const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);
@@ -115,7 +87,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
/* User message bubble on the right */
-
+
{message.content}
{message.images && message.images.length > 0 && (
@@ -166,7 +138,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
🔧
) : (
-
+
)}
@@ -194,7 +166,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<>
-
+
{String(message.displayText || '')}
@@ -210,7 +182,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
onFileOpen={onFileOpen}
createDiff={createDiff}
selectedProject={selectedProject}
- autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
isSubagentContainer={message.isSubagentContainer}
@@ -233,7 +204,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
{t('messageTypes.error')}
-
+
{String(message.toolResult.content || '')}
@@ -250,7 +221,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
onFileOpen={onFileOpen}
createDiff={createDiff}
selectedProject={selectedProject}
- autoExpandTools={autoExpandTools}
/>
)
@@ -342,7 +312,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
-
+
{message.content}
@@ -377,15 +347,15 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
return (
-
+
-
+
-
+
{formatted}
@@ -399,7 +369,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
// Normal rendering for non-JSON content
return message.type === 'assistant' ? (
-
+
{content}
) : (
diff --git a/src/components/chat/view/subcomponents/MessageCopyControl.tsx b/src/components/chat/view/subcomponents/MessageCopyControl.tsx
index aeacd45c..c02b5676 100644
--- a/src/components/chat/view/subcomponents/MessageCopyControl.tsx
+++ b/src/components/chat/view/subcomponents/MessageCopyControl.tsx
@@ -1,4 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
+import type { CSSProperties } from 'react';
+import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { copyTextToClipboard } from '../../../../utils/clipboard';
@@ -49,9 +51,32 @@ const MessageCopyControl = ({
const [selectedFormat, setSelectedFormat] = useState(defaultFormat);
const [copied, setCopied] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [menuStyle, setMenuStyle] = useState({});
const dropdownRef = useRef(null);
+ const triggerRef = useRef(null);
+ const menuRef = useRef(null);
const copyFeedbackTimerRef = useRef | null>(null);
+ // The dropdown is rendered in a portal so it escapes the chat message's
+ // `contain: paint` box (which would otherwise clip it). Anchor it to the
+ // trigger, flipping above when there isn't room below.
+ const openDropdown = () => {
+ const rect = triggerRef.current?.getBoundingClientRect();
+ if (rect) {
+ const ESTIMATED_MENU_HEIGHT = 84;
+ const openUp = rect.bottom + ESTIMATED_MENU_HEIGHT + 8 > window.innerHeight;
+ setMenuStyle({
+ position: 'fixed',
+ right: Math.max(8, window.innerWidth - rect.right),
+ zIndex: 1000,
+ ...(openUp
+ ? { bottom: window.innerHeight - rect.top + 4 }
+ : { top: rect.bottom + 4 }),
+ });
+ }
+ setIsDropdownOpen(true);
+ };
+
const copyFormatOptions: CopyFormatOption[] = useMemo(
() => [
{
@@ -83,18 +108,28 @@ const MessageCopyControl = ({
}, [defaultFormat]);
useEffect(() => {
- // Close the dropdown when clicking anywhere outside this control.
+ if (!isDropdownOpen) return;
+
+ // Close when clicking outside both the control and the portaled menu.
const closeOnOutsideClick = (event: MouseEvent) => {
- if (!isDropdownOpen) return;
const target = event.target as Node;
- if (dropdownRef.current && !dropdownRef.current.contains(target)) {
- setIsDropdownOpen(false);
+ if (dropdownRef.current?.contains(target) || menuRef.current?.contains(target)) {
+ return;
}
+ setIsDropdownOpen(false);
};
+ // The menu is fixed-positioned; close it if the page scrolls so it can't
+ // detach from the trigger.
+ const closeOnScroll = () => setIsDropdownOpen(false);
+
window.addEventListener('mousedown', closeOnOutsideClick);
+ window.addEventListener('scroll', closeOnScroll, true);
+ window.addEventListener('resize', closeOnScroll);
return () => {
window.removeEventListener('mousedown', closeOnOutsideClick);
+ window.removeEventListener('scroll', closeOnScroll, true);
+ window.removeEventListener('resize', closeOnScroll);
};
}, [isDropdownOpen]);
@@ -170,8 +205,9 @@ const MessageCopyControl = ({
{canSelectCopyFormat && (
<>
setIsDropdownOpen((prev) => !prev)}
+ onClick={() => (isDropdownOpen ? setIsDropdownOpen(false) : openDropdown())}
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
@@ -186,8 +222,12 @@ const MessageCopyControl = ({
- {isDropdownOpen && (
-
+ {isDropdownOpen && createPortal(
+
{copyFormatOptions.map((option) => {
const isSelected = option.format === selectedFormat;
return (
@@ -196,15 +236,16 @@ const MessageCopyControl = ({
type="button"
onClick={() => handleFormatChange(option.format)}
className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected
- ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'
- : 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800/60'
+ ? 'bg-accent text-foreground'
+ : 'text-foreground hover:bg-accent'
}`}
>
{option.label}
);
})}
-
+
,
+ document.body,
)}
>
)}
diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
index 6d97ca88..a2bc74e8 100644
--- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
+++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
@@ -186,7 +186,7 @@ export default function ProviderSelectionEmptyState({
if (!selectedSession && !currentSessionId) {
return (
-
+
{t("providerSelection.title")}
@@ -352,7 +352,7 @@ export default function ProviderSelectionEmptyState({
if (selectedSession) {
return (
-
+
{t("session.continue.title")}
diff --git a/src/components/chat/view/subcomponents/TokenUsageSummary.tsx b/src/components/chat/view/subcomponents/TokenUsageSummary.tsx
index f3d25f4b..51dd8ed0 100644
--- a/src/components/chat/view/subcomponents/TokenUsageSummary.tsx
+++ b/src/components/chat/view/subcomponents/TokenUsageSummary.tsx
@@ -43,7 +43,7 @@ export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryP
diff --git a/src/components/chat/view/subcomponents/ToolGroupContainer.tsx b/src/components/chat/view/subcomponents/ToolGroupContainer.tsx
index 5fc5e837..79e1a02d 100644
--- a/src/components/chat/view/subcomponents/ToolGroupContainer.tsx
+++ b/src/components/chat/view/subcomponents/ToolGroupContainer.tsx
@@ -22,7 +22,6 @@ interface ToolGroupContainerProps {
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
- autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject?: Project | null;
@@ -66,7 +65,6 @@ export default function ToolGroupContainer({
onFileOpen,
onShowSettings,
onGrantToolPermission,
- autoExpandTools,
showRawParameters,
showThinking,
selectedProject,
@@ -133,7 +131,6 @@ export default function ToolGroupContainer({
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission}
- autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
diff --git a/src/components/code-editor/constants/settings.ts b/src/components/code-editor/constants/settings.ts
index fe3d5d24..4d8d1068 100644
--- a/src/components/code-editor/constants/settings.ts
+++ b/src/components/code-editor/constants/settings.ts
@@ -1,5 +1,4 @@
export const CODE_EDITOR_STORAGE_KEYS = {
- theme: 'codeEditorTheme',
wordWrap: 'codeEditorWordWrap',
showMinimap: 'codeEditorShowMinimap',
lineNumbers: 'codeEditorLineNumbers',
@@ -7,7 +6,6 @@ export const CODE_EDITOR_STORAGE_KEYS = {
} as const;
export const CODE_EDITOR_DEFAULTS = {
- isDarkMode: true,
wordWrap: false,
minimapEnabled: true,
showLineNumbers: true,
diff --git a/src/components/code-editor/hooks/useCodeEditorDocument.ts b/src/components/code-editor/hooks/useCodeEditorDocument.ts
index b2b7acd2..dda02887 100644
--- a/src/components/code-editor/hooks/useCodeEditorDocument.ts
+++ b/src/components/code-editor/hooks/useCodeEditorDocument.ts
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { api } from '../../../utils/api';
import type { CodeEditorFile } from '../types/types';
import { isBinaryFile } from '../utils/binaryFile';
+import { getPreviewKind } from '../utils/previewableFile';
type UseCodeEditorDocumentParams = {
file: CodeEditorFile;
@@ -23,6 +24,9 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState(null);
const [isBinary, setIsBinary] = useState(false);
+ // Some binaries (images, PDFs, audio, video) can be rendered natively, so the
+ // editor shows an inline preview instead of the generic binary placeholder.
+ const previewKind = getPreviewKind(file.name);
// `fileProjectId` is the DB primary key passed down from the editor sidebar;
// the fallback to `projectPath` preserves older callers that didn't yet
// propagate the identifier.
@@ -38,8 +42,19 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
setLoading(true);
setIsBinary(false);
+ // Natively previewable media (image/pdf/audio/video) is rendered by
+ // CodeEditorMediaPreview, so there is nothing to read as text here.
+ // Clear any buffer left over from a previously opened text file so a
+ // stray save can't write stale content over the binary file.
+ if (getPreviewKind(file.name)) {
+ setContent('');
+ setLoading(false);
+ return;
+ }
+
// Check if file is binary by extension
if (isBinaryFile(file.name)) {
+ setContent('');
setIsBinary(true);
setLoading(false);
return;
@@ -76,6 +91,12 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
const handleSave = useCallback(async () => {
+ // Preview-only and binary files have no editable text buffer; never write
+ // them back (e.g. via Cmd/Ctrl+S) or we'd corrupt the file on disk.
+ if (previewKind || isBinaryFile(fileName)) {
+ return;
+ }
+
setSaving(true);
setSaveError(null);
@@ -109,7 +130,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
} finally {
setSaving(false);
}
- }, [content, filePath, fileProjectId]);
+ }, [content, filePath, fileProjectId, previewKind, fileName]);
const handleDownload = useCallback(() => {
const blob = new Blob([content], { type: 'text/plain' });
@@ -134,6 +155,8 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
saveSuccess,
saveError,
isBinary,
+ previewKind,
+ fileProjectId,
handleSave,
handleDownload,
};
diff --git a/src/components/code-editor/hooks/useCodeEditorSettings.ts b/src/components/code-editor/hooks/useCodeEditorSettings.ts
index 639054d7..06a8ffe4 100644
--- a/src/components/code-editor/hooks/useCodeEditorSettings.ts
+++ b/src/components/code-editor/hooks/useCodeEditorSettings.ts
@@ -5,15 +5,6 @@ import {
CODE_EDITOR_STORAGE_KEYS,
} from '../constants/settings';
-const readTheme = () => {
- const savedTheme = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.theme);
- if (!savedTheme) {
- return CODE_EDITOR_DEFAULTS.isDarkMode;
- }
-
- return savedTheme === 'dark';
-};
-
const readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => {
const value = localStorage.getItem(storageKey);
if (value === null) {
@@ -33,7 +24,6 @@ const readFontSize = () => {
};
export const useCodeEditorSettings = () => {
- const [isDarkMode, setIsDarkMode] = useState(readTheme);
const [wordWrap, setWordWrap] = useState(readWordWrap);
const [minimapEnabled, setMinimapEnabled] = useState(() => (
readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)
@@ -43,18 +33,13 @@ export const useCodeEditorSettings = () => {
));
const [fontSize, setFontSize] = useState(readFontSize);
- // Keep legacy behavior where the editor writes theme and wrap settings directly.
- useEffect(() => {
- localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.theme, isDarkMode ? 'dark' : 'light');
- }, [isDarkMode]);
-
+ // Keep legacy behavior where the editor writes wrap settings directly.
useEffect(() => {
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));
}, [wordWrap]);
useEffect(() => {
const refreshFromStorage = () => {
- setIsDarkMode(readTheme());
setWordWrap(readWordWrap());
setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));
setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));
@@ -71,8 +56,6 @@ export const useCodeEditorSettings = () => {
}, []);
return {
- isDarkMode,
- setIsDarkMode,
wordWrap,
setWordWrap,
minimapEnabled,
diff --git a/src/components/code-editor/utils/previewableFile.ts b/src/components/code-editor/utils/previewableFile.ts
new file mode 100644
index 00000000..0071de4e
--- /dev/null
+++ b/src/components/code-editor/utils/previewableFile.ts
@@ -0,0 +1,63 @@
+// Some binary files can't be edited as text, but the browser can still render
+// them natively (images, PDFs, audio, video). For those we show an inline
+// preview instead of the generic "binary file" placeholder. Anything not listed
+// here (zip, exe, avi, mkv, fonts, ...) falls through to the binary message.
+
+export type PreviewKind = 'image' | 'pdf' | 'video' | 'audio';
+
+// Single source of truth: every extension the browser can preview, mapped to the
+// MIME type we apply when the server response has a missing/generic Content-Type.
+// The preview kind is derived from the MIME type so the two never drift apart.
+// Formats browsers generally can't play (avi, mkv, flv, wmv) are intentionally
+// absent and keep the binary fallback.
+const EXTENSION_MIME: Record = {
+ // Images
+ png: 'image/png',
+ jpg: 'image/jpeg',
+ jpeg: 'image/jpeg',
+ gif: 'image/gif',
+ svg: 'image/svg+xml',
+ webp: 'image/webp',
+ ico: 'image/x-icon',
+ bmp: 'image/bmp',
+ avif: 'image/avif',
+ apng: 'image/apng',
+ // PDF
+ pdf: 'application/pdf',
+ // Video
+ mp4: 'video/mp4',
+ webm: 'video/webm',
+ ogv: 'video/ogg',
+ mov: 'video/quicktime',
+ m4v: 'video/x-m4v',
+ // Audio
+ mp3: 'audio/mpeg',
+ wav: 'audio/wav',
+ m4a: 'audio/mp4',
+ aac: 'audio/aac',
+ flac: 'audio/flac',
+ opus: 'audio/opus',
+ oga: 'audio/ogg',
+ ogg: 'audio/ogg',
+ weba: 'audio/webm',
+};
+
+const extensionOf = (filename: string): string => filename.split('.').pop()?.toLowerCase() ?? '';
+
+const kindForMime = (mime: string): PreviewKind | null => {
+ if (mime === 'application/pdf') return 'pdf';
+ if (mime.startsWith('image/')) return 'image';
+ if (mime.startsWith('video/')) return 'video';
+ if (mime.startsWith('audio/')) return 'audio';
+ return null;
+};
+
+export const getPreviewKind = (filename: string): PreviewKind | null => {
+ const mime = EXTENSION_MIME[extensionOf(filename)];
+ return mime ? kindForMime(mime) : null;
+};
+
+// MIME type to fall back to when the server returns no/generic Content-Type.
+// Returns undefined for non-previewable extensions.
+export const getPreviewMimeType = (filename: string): string | undefined =>
+ EXTENSION_MIME[extensionOf(filename)];
diff --git a/src/components/code-editor/view/CodeEditor.tsx b/src/components/code-editor/view/CodeEditor.tsx
index 5861ce71..c899718c 100644
--- a/src/components/code-editor/view/CodeEditor.tsx
+++ b/src/components/code-editor/view/CodeEditor.tsx
@@ -1,9 +1,11 @@
import { EditorView } from '@codemirror/view';
import { unifiedMergeView } from '@codemirror/merge';
import type { Extension } from '@codemirror/state';
-import { useMemo, useState } from 'react';
+import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
+
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
+import { useTheme } from '../../../contexts/ThemeContext';
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
@@ -11,11 +13,13 @@ import type { CodeEditorFile } from '../types/types';
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
import { getEditorStyles } from '../utils/editorStyles';
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
+
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
+import CodeEditorMediaPreview from './subcomponents/CodeEditorMediaPreview';
type CodeEditorProps = {
file: CodeEditorFile;
@@ -42,8 +46,10 @@ export default function CodeEditor({
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
const [markdownPreview, setMarkdownPreview] = useState(false);
+ // The code editor follows the app-wide theme; it has no theme of its own.
+ const { isDarkMode } = useTheme();
+
const {
- isDarkMode,
wordWrap,
minimapEnabled,
showLineNumbers,
@@ -58,6 +64,8 @@ export default function CodeEditor({
saveSuccess,
saveError,
isBinary,
+ previewKind,
+ fileProjectId,
handleSave,
handleDownload,
} = useCodeEditorDocument({
@@ -70,6 +78,29 @@ export default function CodeEditor({
return extension === 'md' || extension === 'markdown';
}, [file.name]);
+ const isHtmlPreviewFile = useMemo(() => {
+ const extension = file.name.split('.').pop()?.toLowerCase();
+ return extension === 'html' || extension === 'htm';
+ }, [file.name]);
+
+ const openHtmlPreview = useCallback(() => {
+ const previewWindow = window.open('', '_blank');
+ if (!previewWindow) return;
+
+ previewWindow.opener = null;
+ previewWindow.document.title = file.name;
+ previewWindow.document.body.style.margin = '0';
+
+ const iframe = previewWindow.document.createElement('iframe');
+ iframe.title = file.name;
+ iframe.sandbox.add('allow-forms', 'allow-modals', 'allow-popups', 'allow-scripts');
+ iframe.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0;background:white';
+
+ iframe.srcdoc = content;
+
+ previewWindow.document.body.appendChild(iframe);
+ }, [content, file.name]);
+
const minimapExtension = useMemo(
() => (
createMinimapExtension({
@@ -162,6 +193,30 @@ export default function CodeEditor({
);
}
+ // Natively previewable media (image/pdf/audio/video) is rendered inline
+ // instead of showing the generic "cannot be displayed" placeholder.
+ if (previewKind) {
+ return (
+ setIsFullscreen((previous) => !previous)}
+ labels={{
+ loading: t('filePreview.loading', 'Loading preview...'),
+ error: t('filePreview.error', 'Unable to display this file.'),
+ openInNewTab: t('filePreview.openInNewTab', 'Open in new tab'),
+ fullscreen: t('actions.fullscreen', 'Fullscreen'),
+ exitFullscreen: t('actions.exitFullscreen', 'Exit fullscreen'),
+ close: t('actions.close', 'Close'),
+ }}
+ />
+ );
+ }
+
// Binary file display
if (isBinary) {
return (
@@ -197,10 +252,12 @@ export default function CodeEditor({
isSidebar={isSidebar}
isFullscreen={isFullscreen}
isMarkdownFile={isMarkdownFile}
+ isHtmlPreviewFile={isHtmlPreviewFile}
markdownPreview={markdownPreview}
saving={saving}
saveSuccess={saveSuccess}
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
+ onOpenHtmlPreview={openHtmlPreview}
onOpenSettings={() => paletteOps.openSettings('appearance')}
onDownload={handleDownload}
onSave={handleSave}
@@ -210,6 +267,7 @@ export default function CodeEditor({
showingChanges: t('header.showingChanges'),
editMarkdown: t('actions.editMarkdown'),
previewMarkdown: t('actions.previewMarkdown'),
+ previewHtml: t('actions.previewHtml', 'Open HTML preview in new tab'),
settings: t('toolbar.settings'),
download: t('actions.download'),
save: t('actions.save'),
diff --git a/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx b/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx
index 7c429a63..d16f21f5 100644
--- a/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx
+++ b/src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx
@@ -1,4 +1,5 @@
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
+
import type { CodeEditorFile } from '../../types/types';
type CodeEditorHeaderProps = {
@@ -6,10 +7,12 @@ type CodeEditorHeaderProps = {
isSidebar: boolean;
isFullscreen: boolean;
isMarkdownFile: boolean;
+ isHtmlPreviewFile: boolean;
markdownPreview: boolean;
saving: boolean;
saveSuccess: boolean;
onToggleMarkdownPreview: () => void;
+ onOpenHtmlPreview: () => void;
onOpenSettings: () => void;
onDownload: () => void;
onSave: () => void;
@@ -19,6 +22,7 @@ type CodeEditorHeaderProps = {
showingChanges: string;
editMarkdown: string;
previewMarkdown: string;
+ previewHtml: string;
settings: string;
download: string;
save: string;
@@ -35,10 +39,12 @@ export default function CodeEditorHeader({
isSidebar,
isFullscreen,
isMarkdownFile,
+ isHtmlPreviewFile,
markdownPreview,
saving,
saveSuccess,
onToggleMarkdownPreview,
+ onOpenHtmlPreview,
onOpenSettings,
onDownload,
onSave,
@@ -82,6 +88,17 @@ export default function CodeEditorHeader({
)}
+ {isHtmlPreviewFile && (
+
+
+
+ )}
+
void;
+ onToggleFullscreen: () => void;
+ labels: {
+ loading: string;
+ error: string;
+ openInNewTab: string;
+ fullscreen: string;
+ exitFullscreen: string;
+ close: string;
+ };
+};
+
+// Reject a "PDF" whose bytes aren't actually a PDF before handing it to the
+// same-origin iframe, so a mislabeled HTML/SVG file can't run in the app origin.
+const PDF_HEADER_SCAN_BYTES = 1024;
+
+const looksLikePdf = async (blob: Blob): Promise => {
+ const header = await blob.slice(0, PDF_HEADER_SCAN_BYTES).arrayBuffer();
+ // PDFs must contain the "%PDF-" marker at the very start of the file.
+ return new TextDecoder('latin1').decode(header).includes('%PDF-');
+};
+
+export default function CodeEditorMediaPreview({
+ file,
+ kind,
+ projectId,
+ isSidebar,
+ isFullscreen,
+ onClose,
+ onToggleFullscreen,
+ labels,
+}: CodeEditorMediaPreviewProps) {
+ const [url, setUrl] = useState(null);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+ // Identifies which file the current `url` was loaded for. Rendering is gated on
+ // this so a blob from a previously-opened file can never show under the new
+ // file (the editor reuses this component instance across files).
+ const [loadedKey, setLoadedKey] = useState(null);
+ const sourceKey = `${projectId ?? ''}:${file.path}:${kind}`;
+
+ useEffect(() => {
+ if (!projectId) {
+ setUrl(null);
+ setLoadedKey(null);
+ setError(labels.error);
+ setLoading(false);
+ return;
+ }
+
+ let objectUrl: string | null = null;
+ const controller = new AbortController();
+
+ const loadMedia = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ setUrl(null);
+
+ // The content endpoint requires the auth header, so we fetch the bytes
+ // ourselves and hand the media element a blob URL instead of a bare src.
+ // Fetching a blob (rather than streaming) also lets / seek.
+ const contentUrl = `/api/projects/${projectId}/files/content?path=${encodeURIComponent(file.path)}`;
+ const response = await authenticatedFetch(contentUrl, { signal: controller.signal });
+
+ if (!response.ok) {
+ throw new Error(`Request failed with status ${response.status}`);
+ }
+
+ const blob = await response.blob();
+
+ // Pick the MIME type to expose to the browser. Preserve a valid
+ // Content-Type from the server, but supply an extension-specific
+ // default when it is missing or generic (application/octet-stream),
+ // otherwise formats like webm/ogg/flac/svg won't render.
+ const fallbackMime = getPreviewMimeType(file.name);
+ const isGenericType = !blob.type || blob.type === 'application/octet-stream';
+ const isMislabeledVideo = kind === 'video' && Boolean(fallbackMime) && !blob.type.startsWith('video/');
+ let outType = isGenericType || isMislabeledVideo ? (fallbackMime ?? blob.type) : blob.type;
+
+ if (kind === 'pdf') {
+ // The PDF renders in a same-origin