mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-03 20:03:01 +08:00
fix: use portal for showing effort dropdown
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import type {
|
import type {
|
||||||
ChangeEvent,
|
ChangeEvent,
|
||||||
ClipboardEvent,
|
ClipboardEvent,
|
||||||
@@ -10,7 +11,7 @@ import type {
|
|||||||
RefObject,
|
RefObject,
|
||||||
TouchEvent,
|
TouchEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2, ChevronDown, Check } from 'lucide-react';
|
import { ImageIcon, MessageSquareIcon, XIcon, Loader2, ChevronDown, Check } from 'lucide-react';
|
||||||
|
|
||||||
import { useVoiceInput } from '../../hooks/useVoiceInput';
|
import { useVoiceInput } from '../../hooks/useVoiceInput';
|
||||||
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
|
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
|
||||||
@@ -197,17 +198,40 @@ export default function ChatComposer({
|
|||||||
const isTranscribing = voiceState === 'transcribing';
|
const isTranscribing = voiceState === 'transcribing';
|
||||||
const [isEffortDropdownOpen, setIsEffortDropdownOpen] = useState(false);
|
const [isEffortDropdownOpen, setIsEffortDropdownOpen] = useState(false);
|
||||||
const effortDropdownRef = useRef<HTMLDivElement | null>(null);
|
const effortDropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const effortDropdownMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const effortDropdownButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const [effortDropdownPosition, setEffortDropdownPosition] = useState<{
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
maxHeight: number;
|
||||||
|
} | null>(null);
|
||||||
const effortOptions = useMemo(
|
const effortOptions = useMemo(
|
||||||
() => [{ value: 'default' }, ...availableEffortOptions],
|
() => [{ value: 'default' }, ...availableEffortOptions],
|
||||||
[availableEffortOptions],
|
[availableEffortOptions],
|
||||||
);
|
);
|
||||||
const selectedEffortLabel = effort === 'default' ? 'Default' : effort;
|
const selectedEffortLabel = effort === 'default' ? 'Default' : effort;
|
||||||
|
const updateEffortDropdownPosition = useCallback(() => {
|
||||||
|
const rect = effortDropdownButtonRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEffortDropdownPosition({
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.top - 8,
|
||||||
|
maxHeight: Math.max(96, rect.top - 16),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEffortDropdownOpen) return;
|
if (!isEffortDropdownOpen) return;
|
||||||
|
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
if (!effortDropdownRef.current?.contains(event.target as Node)) {
|
const target = event.target as Node;
|
||||||
|
if (
|
||||||
|
!effortDropdownRef.current?.contains(target)
|
||||||
|
&& !effortDropdownMenuRef.current?.contains(target)
|
||||||
|
) {
|
||||||
setIsEffortDropdownOpen(false);
|
setIsEffortDropdownOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -221,13 +245,18 @@ export default function ChatComposer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('pointerdown', handlePointerDown);
|
document.addEventListener('pointerdown', handlePointerDown);
|
||||||
|
window.addEventListener('resize', updateEffortDropdownPosition);
|
||||||
|
window.addEventListener('scroll', updateEffortDropdownPosition, true);
|
||||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||||
|
updateEffortDropdownPosition();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('pointerdown', handlePointerDown);
|
document.removeEventListener('pointerdown', handlePointerDown);
|
||||||
|
window.removeEventListener('resize', updateEffortDropdownPosition);
|
||||||
|
window.removeEventListener('scroll', updateEffortDropdownPosition, true);
|
||||||
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||||
};
|
};
|
||||||
}, [isEffortDropdownOpen]);
|
}, [isEffortDropdownOpen, updateEffortDropdownPosition]);
|
||||||
|
|
||||||
// Detect if the AskUserQuestion interactive panel is active
|
// Detect if the AskUserQuestion interactive panel is active
|
||||||
const hasQuestionPanel = pendingPermissionRequests.some(
|
const hasQuestionPanel = pendingPermissionRequests.some(
|
||||||
@@ -418,8 +447,12 @@ export default function ChatComposer({
|
|||||||
{availableEffortOptions.length > 0 && (
|
{availableEffortOptions.length > 0 && (
|
||||||
<div ref={effortDropdownRef} className="relative">
|
<div ref={effortDropdownRef} className="relative">
|
||||||
<button
|
<button
|
||||||
|
ref={effortDropdownButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsEffortDropdownOpen((current) => !current)}
|
onClick={() => {
|
||||||
|
updateEffortDropdownPosition();
|
||||||
|
setIsEffortDropdownOpen((current) => !current);
|
||||||
|
}}
|
||||||
className="flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-muted/40 px-2 text-xs font-medium text-foreground transition-all duration-200 hover:bg-muted"
|
className="flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-muted/40 px-2 text-xs font-medium text-foreground transition-all duration-200 hover:bg-muted"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={isEffortDropdownOpen}
|
aria-expanded={isEffortDropdownOpen}
|
||||||
@@ -431,8 +464,18 @@ export default function ChatComposer({
|
|||||||
<ChevronDown className={`h-3 w-3 text-muted-foreground transition-transform ${isEffortDropdownOpen ? 'rotate-180' : ''}`} />
|
<ChevronDown className={`h-3 w-3 text-muted-foreground transition-transform ${isEffortDropdownOpen ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isEffortDropdownOpen && (
|
{isEffortDropdownOpen && effortDropdownPosition && createPortal(
|
||||||
<div className="absolute bottom-full left-0 z-50 mb-2 min-w-36 overflow-hidden rounded-lg border border-border bg-card p-1 shadow-lg">
|
<div
|
||||||
|
ref={effortDropdownMenuRef}
|
||||||
|
className="fixed z-[100] min-w-36 overflow-y-auto rounded-lg border border-border bg-card p-1 shadow-lg"
|
||||||
|
style={{
|
||||||
|
left: effortDropdownPosition.left,
|
||||||
|
top: effortDropdownPosition.top,
|
||||||
|
maxHeight: effortDropdownPosition.maxHeight,
|
||||||
|
transform: 'translateY(-100%)',
|
||||||
|
}}
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
{effortOptions.map((option) => {
|
{effortOptions.map((option) => {
|
||||||
const isSelected = option.value === effort;
|
const isSelected = option.value === effort;
|
||||||
const label = option.value === 'default' ? 'Default' : option.value;
|
const label = option.value === 'default' ? 'Default' : option.value;
|
||||||
@@ -459,7 +502,8 @@ export default function ChatComposer({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user