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 (