mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-13 17:11:30 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7a5baf147 | ||
|
|
e2459cb0f8 |
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -20,14 +20,14 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.RELEASE_PAT }}
|
token: ${{ secrets.RELEASE_PAT }}
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
|
|
||||||
- name: git config
|
- name: git config
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect, useCallback, type CSSProperties } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { Brain, X } from 'lucide-react';
|
import { Brain, X } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { thinkingModes } from '../../constants/thinkingModes';
|
import { thinkingModes } from '../../constants/thinkingModes';
|
||||||
@@ -12,6 +13,11 @@ type ThinkingModeSelectorProps = {
|
|||||||
|
|
||||||
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
|
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
|
||||||
|
|
||||||
// Mapping from mode ID to translation key
|
// Mapping from mode ID to translation key
|
||||||
const modeKeyMap: Record<string, string> = {
|
const modeKeyMap: Record<string, string> = {
|
||||||
@@ -29,50 +35,143 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const closeDropdown = useCallback(() => {
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
setIsOpen(false);
|
||||||
|
onClose?.();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const updateDropdownPosition = useCallback(() => {
|
||||||
|
const trigger = triggerRef.current;
|
||||||
|
const dropdown = dropdownRef.current;
|
||||||
|
if (!trigger || !dropdown || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerRect = trigger.getBoundingClientRect();
|
||||||
|
const viewportPadding = window.innerWidth < 640 ? 12 : 16;
|
||||||
|
const spacing = 8;
|
||||||
|
const width = Math.min(window.innerWidth - viewportPadding * 2, window.innerWidth < 640 ? 320 : 256);
|
||||||
|
let left = triggerRect.left + triggerRect.width / 2 - width / 2;
|
||||||
|
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - width - viewportPadding));
|
||||||
|
|
||||||
|
const measuredHeight = dropdown.offsetHeight || 0;
|
||||||
|
const spaceBelow = window.innerHeight - triggerRect.bottom - spacing - viewportPadding;
|
||||||
|
const spaceAbove = triggerRect.top - spacing - viewportPadding;
|
||||||
|
const openBelow = spaceBelow >= Math.min(measuredHeight || 320, 320) || spaceBelow >= spaceAbove;
|
||||||
|
const availableHeight = Math.min(
|
||||||
|
window.innerHeight - viewportPadding * 2,
|
||||||
|
Math.max(180, openBelow ? spaceBelow : spaceAbove),
|
||||||
|
);
|
||||||
|
const panelHeight = Math.min(measuredHeight || availableHeight, availableHeight);
|
||||||
|
const top = openBelow
|
||||||
|
? Math.min(triggerRect.bottom + spacing, window.innerHeight - viewportPadding - panelHeight)
|
||||||
|
: Math.max(viewportPadding, triggerRect.top - spacing - panelHeight);
|
||||||
|
|
||||||
|
setDropdownStyle({
|
||||||
|
position: 'fixed',
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
width,
|
||||||
|
maxHeight: availableHeight,
|
||||||
|
zIndex: 80,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
if (!isOpen) {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
setDropdownStyle(null);
|
||||||
setIsOpen(false);
|
return;
|
||||||
if (onClose) onClose();
|
}
|
||||||
|
|
||||||
|
const rafId = window.requestAnimationFrame(updateDropdownPosition);
|
||||||
|
const handleViewportChange = () => updateDropdownPosition();
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleViewportChange);
|
||||||
|
window.addEventListener('scroll', handleViewportChange, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(rafId);
|
||||||
|
window.removeEventListener('resize', handleViewportChange);
|
||||||
|
window.removeEventListener('scroll', handleViewportChange, true);
|
||||||
|
};
|
||||||
|
}, [isOpen, updateDropdownPosition]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerRef.current?.contains(target) || dropdownRef.current?.contains(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeDropdown();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
}, [onClose]);
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, closeDropdown]);
|
||||||
|
|
||||||
const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];
|
const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];
|
||||||
const IconComponent = currentMode.icon || Brain;
|
const IconComponent = currentMode.icon || Brain;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${className}`} ref={dropdownRef}>
|
<div className={`relative ${className}`} ref={containerRef}>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => {
|
||||||
|
if (isOpen) {
|
||||||
|
closeDropdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 sm:h-10 sm:w-10 ${selectedMode === 'none'
|
className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 sm:h-10 sm:w-10 ${selectedMode === 'none'
|
||||||
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
|
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
|
||||||
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
|
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
|
||||||
}`}
|
}`}
|
||||||
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
|
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={isOpen}
|
||||||
>
|
>
|
||||||
<IconComponent className={`h-5 w-5 ${currentMode.color}`} />
|
<IconComponent className={`h-5 w-5 ${currentMode.color}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||||
<div className="absolute bottom-full right-0 mb-2 w-64 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800">
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
style={dropdownStyle || { position: 'fixed', top: 0, left: 0, visibility: 'hidden' }}
|
||||||
|
className="flex flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="false"
|
||||||
|
>
|
||||||
<div className="border-b border-gray-200 p-3 dark:border-gray-700">
|
<div className="border-b border-gray-200 p-3 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
{t('thinkingMode.selector.title')}
|
{t('thinkingMode.selector.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
setIsOpen(false);
|
onClick={closeDropdown}
|
||||||
if (onClose) onClose();
|
|
||||||
}}
|
|
||||||
className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
|
className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 text-gray-500" />
|
<X className="h-4 w-4 text-gray-500" />
|
||||||
@@ -83,7 +182,7 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="py-1">
|
<div className="min-h-0 overflow-y-auto py-1">
|
||||||
{translatedModes.map((mode) => {
|
{translatedModes.map((mode) => {
|
||||||
const ModeIcon = mode.icon;
|
const ModeIcon = mode.icon;
|
||||||
const isSelected = mode.id === selectedMode;
|
const isSelected = mode.id === selectedMode;
|
||||||
@@ -91,10 +190,10 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={mode.id}
|
key={mode.id}
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onModeChange(mode.id);
|
onModeChange(mode.id);
|
||||||
setIsOpen(false);
|
closeDropdown();
|
||||||
if (onClose) onClose();
|
|
||||||
}}
|
}}
|
||||||
className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
|
className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
|
||||||
}`}
|
}`}
|
||||||
@@ -135,10 +234,11 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
|||||||
<strong>Tip:</strong> {t('thinkingMode.selector.tip')}
|
<strong>Tip:</strong> {t('thinkingMode.selector.tip')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ThinkingModeSelector;
|
export default ThinkingModeSelector;
|
||||||
|
|||||||
Reference in New Issue
Block a user