mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-01 10:02:57 +08:00
fix(chat): header ellipsis, Codex logo on light theme, portal copy menu
- MainContentTitle: truncate the session title with an ellipsis instead of horizontal-scrolling it - MessageComponent: use text-foreground for the provider logo chip so the currentColor Codex/OpenAI mark is visible on the light theme - MessageCopyControl: render the copy-format dropdown in a portal so it escapes the chat message's `contain: paint` clip box; anchor it to the trigger, flip above near the viewport bottom, close on scroll/resize
This commit is contained in:
@@ -166,7 +166,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
||||
🔧
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-white">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-foreground">
|
||||
<SessionProviderLogo provider={provider} className="h-full w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<CopyFormat>(defaultFormat);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | 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 && (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => 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 = ({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute left-auto top-full z-30 mt-1 min-w-36 rounded-md border border-gray-200 bg-white p-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
|
||||
{isDropdownOpen && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={menuStyle}
|
||||
className="min-w-36 rounded-md border border-border bg-popover p-1 shadow-lg"
|
||||
>
|
||||
{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'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-xs font-medium">{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function MainContentTitle({
|
||||
<div className="min-w-0 flex-1">
|
||||
{activeTab === 'chat' && selectedSession ? (
|
||||
<div className="min-w-0">
|
||||
<h2 className="scrollbar-hide overflow-x-auto whitespace-nowrap text-sm font-semibold leading-tight text-foreground">
|
||||
<h2 className="truncate text-sm font-semibold leading-tight text-foreground">
|
||||
{getSessionTitle(selectedSession)}
|
||||
</h2>
|
||||
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div>
|
||||
|
||||
Reference in New Issue
Block a user