diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx
index b326a876..552b31cb 100644
--- a/src/components/chat/view/subcomponents/MessageComponent.tsx
+++ b/src/components/chat/view/subcomponents/MessageComponent.tsx
@@ -166,7 +166,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
🔧
) : (
-
+
)}
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 && (
<>
- {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/main-content/view/subcomponents/MainContentTitle.tsx b/src/components/main-content/view/subcomponents/MainContentTitle.tsx
index af8b0dec..e3d8776f 100644
--- a/src/components/main-content/view/subcomponents/MainContentTitle.tsx
+++ b/src/components/main-content/view/subcomponents/MainContentTitle.tsx
@@ -70,7 +70,7 @@ export default function MainContentTitle({
{activeTab === 'chat' && selectedSession ? (
-
+
{getSessionTitle(selectedSession)}
{selectedProject.displayName}