mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-07 15:07:38 +00:00
refactor(quick-settings): migrate panel to typed feature-based modules
Refactor QuickSettingsPanel from a single JSX component into a modular TypeScript feature structure while preserving behavior and translations. Highlights: - Replace legacy src/components/QuickSettingsPanel.jsx with a typed entrypoint (src/components/QuickSettingsPanel.tsx). - Introduce src/components/quick-settings-panel/ with clear separation of concerns: - view/: panel shell, header, handle, section wrappers, toggle rows, and content sections. - hooks/: drag interactions and whisper mode persistence. - constants.ts and types.ts for shared config and strict local typing. - Move drag logic into useQuickSettingsDrag with explicit touch/mouse handling, drag threshold detection, click suppression after drag, position clamping, and localStorage persistence. - Keep user-visible behavior intact: - same open/close panel interactions. - same mobile/desktop drag behavior and persisted handle position. - same quick preference toggles and wiring to useUiPreferences. - same hidden whisper section behavior and localStorage/event updates. - Improve readability and maintainability by extracting repetitive setting rows and section scaffolding into reusable components. - Add focused comments around non-obvious behavior (drag click suppression, touch scroll lock, hidden whisper section intent). - Keep files small and reviewable (all new/changed files are under 300 lines). Validation: - npm run typecheck - npm run build
This commit is contained in:
@@ -1,448 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
Eye,
|
||||
Settings2,
|
||||
Moon,
|
||||
Sun,
|
||||
ArrowDown,
|
||||
Mic,
|
||||
Brain,
|
||||
Sparkles,
|
||||
FileText,
|
||||
Languages,
|
||||
GripVertical
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DarkModeToggle } from '../shared/view/ui';
|
||||
|
||||
import { useUiPreferences } from '../hooks/useUiPreferences';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import LanguageSelector from './LanguageSelector';
|
||||
|
||||
import { useDeviceSettings } from '../hooks/useDeviceSettings';
|
||||
|
||||
|
||||
const QuickSettingsPanel = () => {
|
||||
const { t } = useTranslation('settings');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [whisperMode, setWhisperMode] = useState(() => {
|
||||
return localStorage.getItem('whisperMode') || 'default';
|
||||
});
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
||||
|
||||
const { preferences, setPreference } = useUiPreferences();
|
||||
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
|
||||
|
||||
// Draggable handle state
|
||||
const [handlePosition, setHandlePosition] = useState(() => {
|
||||
const saved = localStorage.getItem('quickSettingsHandlePosition');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
return parsed.y ?? 50;
|
||||
} catch {
|
||||
// Remove corrupted data
|
||||
localStorage.removeItem('quickSettingsHandlePosition');
|
||||
return 50;
|
||||
}
|
||||
}
|
||||
return 50; // Default to 50% (middle of screen)
|
||||
});
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStartY, setDragStartY] = useState(0);
|
||||
const [dragStartPosition, setDragStartPosition] = useState(0);
|
||||
const [hasMoved, setHasMoved] = useState(false); // Track if user has moved during drag
|
||||
const handleRef = useRef(null);
|
||||
const constraintsRef = useRef({ min: 10, max: 90 }); // Percentage constraints
|
||||
const dragThreshold = 5; // Pixels to move before it's considered a drag
|
||||
|
||||
// Save handle position to localStorage when it changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem('quickSettingsHandlePosition', JSON.stringify({ y: handlePosition }));
|
||||
}, [handlePosition]);
|
||||
|
||||
// Calculate position from percentage
|
||||
const getPositionStyle = useCallback(() => {
|
||||
if (isMobile) {
|
||||
// On mobile, convert percentage to pixels from bottom
|
||||
const bottomPixels = (window.innerHeight * handlePosition) / 100;
|
||||
return { bottom: `${bottomPixels}px` };
|
||||
} else {
|
||||
// On desktop, use top with percentage
|
||||
return { top: `${handlePosition}%`, transform: 'translateY(-50%)' };
|
||||
}
|
||||
}, [handlePosition, isMobile]);
|
||||
|
||||
// Handle mouse/touch start
|
||||
const handleDragStart = useCallback((e) => {
|
||||
// Don't prevent default yet - we want to allow click if no drag happens
|
||||
e.stopPropagation();
|
||||
|
||||
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
|
||||
setDragStartY(clientY);
|
||||
setDragStartPosition(handlePosition);
|
||||
setHasMoved(false);
|
||||
setIsDragging(false); // Don't set dragging until threshold is passed
|
||||
}, [handlePosition]);
|
||||
|
||||
// Handle mouse/touch move
|
||||
const handleDragMove = useCallback((e) => {
|
||||
if (dragStartY === 0) return; // Not in a potential drag
|
||||
|
||||
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
|
||||
const deltaY = Math.abs(clientY - dragStartY);
|
||||
|
||||
// Check if we've moved past threshold
|
||||
if (!isDragging && deltaY > dragThreshold) {
|
||||
setIsDragging(true);
|
||||
setHasMoved(true);
|
||||
document.body.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
// Prevent body scroll on mobile during drag
|
||||
if (e.type.includes('touch')) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.width = '100%';
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDragging) return;
|
||||
|
||||
// Prevent scrolling on touch move
|
||||
if (e.type.includes('touch')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const actualDeltaY = clientY - dragStartY;
|
||||
|
||||
// For top-based positioning (desktop), moving down increases top percentage
|
||||
// For bottom-based positioning (mobile), we need to invert
|
||||
let percentageDelta;
|
||||
if (isMobile) {
|
||||
// On mobile, moving down should decrease bottom position (increase percentage from top)
|
||||
percentageDelta = -(actualDeltaY / window.innerHeight) * 100;
|
||||
} else {
|
||||
// On desktop, moving down should increase top position
|
||||
percentageDelta = (actualDeltaY / window.innerHeight) * 100;
|
||||
}
|
||||
|
||||
let newPosition = dragStartPosition + percentageDelta;
|
||||
|
||||
// Apply constraints
|
||||
newPosition = Math.max(constraintsRef.current.min, Math.min(constraintsRef.current.max, newPosition));
|
||||
|
||||
setHandlePosition(newPosition);
|
||||
}, [isDragging, dragStartY, dragStartPosition, isMobile, dragThreshold]);
|
||||
|
||||
// Handle mouse/touch end
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
setDragStartY(0);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
// Restore body scroll on mobile
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}, []);
|
||||
|
||||
// Cleanup body styles on unmount in case component unmounts while dragging
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Set up global event listeners for drag
|
||||
useEffect(() => {
|
||||
if (dragStartY !== 0) {
|
||||
// Mouse events
|
||||
const handleMouseMove = (e) => handleDragMove(e);
|
||||
const handleMouseUp = () => handleDragEnd();
|
||||
|
||||
// Touch events
|
||||
const handleTouchMove = (e) => handleDragMove(e);
|
||||
const handleTouchEnd = () => handleDragEnd();
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}
|
||||
}, [dragStartY, handleDragMove, handleDragEnd]);
|
||||
|
||||
const handleToggle = (e) => {
|
||||
// Don't toggle if user was dragging
|
||||
if (hasMoved) {
|
||||
e.preventDefault();
|
||||
setHasMoved(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen((previous) => !previous);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Pull Tab - Combined drag handle and toggle button */}
|
||||
<button
|
||||
ref={handleRef}
|
||||
onClick={handleToggle}
|
||||
onMouseDown={(e) => {
|
||||
// Start drag on mousedown
|
||||
handleDragStart(e);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
// Start drag on touchstart
|
||||
handleDragStart(e);
|
||||
}}
|
||||
className={`fixed ${
|
||||
isOpen ? 'right-64' : 'right-0'
|
||||
} z-50 ${isDragging ? '' : 'transition-all duration-150 ease-out'} bg-white dark:bg-gray-800 border ${
|
||||
isDragging ? 'border-blue-500 dark:border-blue-400' : 'border-gray-200 dark:border-gray-700'
|
||||
} rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg ${
|
||||
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
|
||||
} touch-none`}
|
||||
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
|
||||
aria-label={isDragging ? t('quickSettings.dragHandle.dragging') : isOpen ? t('quickSettings.dragHandle.closePanel') : t('quickSettings.dragHandle.openPanel')}
|
||||
title={isDragging ? t('quickSettings.dragHandle.draggingStatus') : t('quickSettings.dragHandle.toggleAndMove')}
|
||||
>
|
||||
{isDragging ? (
|
||||
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
|
||||
) : isOpen ? (
|
||||
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
) : (
|
||||
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-64 bg-background border-l border-border shadow-xl transform transition-transform duration-150 ease-out z-40 ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
} ${isMobile ? 'h-screen' : ''}`}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
{t('quickSettings.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Settings Content */}
|
||||
<div className={`flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6 bg-background ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||
{/* Appearance Settings */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.appearance')}</h4>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
{isDarkMode ? <Moon className="h-4 w-4 text-gray-600 dark:text-gray-400" /> : <Sun className="h-4 w-4 text-gray-600 dark:text-gray-400" />}
|
||||
{t('quickSettings.darkMode')}
|
||||
</span>
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
|
||||
{/* Language Selector */}
|
||||
<div>
|
||||
<LanguageSelector compact={true} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool Display Settings */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.toolDisplay')}</h4>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Maximize2 className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
{t('quickSettings.autoExpandTools')}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoExpandTools}
|
||||
onChange={(e) => setPreference('autoExpandTools', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Eye className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
{t('quickSettings.showRawParameters')}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showRawParameters}
|
||||
onChange={(e) => setPreference('showRawParameters', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Brain className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
{t('quickSettings.showThinking')}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showThinking}
|
||||
onChange={(e) => setPreference('showThinking', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/* View Options */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.viewOptions')}</h4>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<ArrowDown className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
{t('quickSettings.autoScrollToBottom')}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScrollToBottom}
|
||||
onChange={(e) => setPreference('autoScrollToBottom', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Input Settings */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.inputSettings')}</h4>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
{t('quickSettings.sendByCtrlEnter')}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sendByCtrlEnter}
|
||||
onChange={(e) => setPreference('sendByCtrlEnter', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 ml-3">
|
||||
{t('quickSettings.sendByCtrlEnterDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Whisper Dictation Settings - HIDDEN */}
|
||||
<div className="space-y-2" style={{ display: 'none' }}>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.whisperDictation')}</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
name="whisperMode"
|
||||
value="default"
|
||||
checked={whisperMode === 'default'}
|
||||
onChange={() => {
|
||||
setWhisperMode('default');
|
||||
localStorage.setItem('whisperMode', 'default');
|
||||
window.dispatchEvent(new Event('whisperModeChanged'));
|
||||
}}
|
||||
className="mt-0.5 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
/>
|
||||
<div className="ml-3 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<Mic className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
{t('quickSettings.whisper.modes.default')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('quickSettings.whisper.modes.defaultDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
name="whisperMode"
|
||||
value="prompt"
|
||||
checked={whisperMode === 'prompt'}
|
||||
onChange={() => {
|
||||
setWhisperMode('prompt');
|
||||
localStorage.setItem('whisperMode', 'prompt');
|
||||
window.dispatchEvent(new Event('whisperModeChanged'));
|
||||
}}
|
||||
className="mt-0.5 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
/>
|
||||
<div className="ml-3 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<Sparkles className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
{t('quickSettings.whisper.modes.prompt')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('quickSettings.whisper.modes.promptDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
name="whisperMode"
|
||||
value="vibe"
|
||||
checked={whisperMode === 'vibe' || whisperMode === 'instructions' || whisperMode === 'architect'}
|
||||
onChange={() => {
|
||||
setWhisperMode('vibe');
|
||||
localStorage.setItem('whisperMode', 'vibe');
|
||||
window.dispatchEvent(new Event('whisperModeChanged'));
|
||||
}}
|
||||
className="mt-0.5 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
/>
|
||||
<div className="ml-3 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
{t('quickSettings.whisper.modes.vibe')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('quickSettings.whisper.modes.vibeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backdrop */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-30 transition-opacity duration-150 ease-out"
|
||||
onClick={handleToggle}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickSettingsPanel;
|
||||
5
src/components/QuickSettingsPanel.tsx
Normal file
5
src/components/QuickSettingsPanel.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import QuickSettingsPanelView from './quick-settings-panel/view/QuickSettingsPanelView';
|
||||
|
||||
export default function QuickSettingsPanel() {
|
||||
return <QuickSettingsPanelView />;
|
||||
}
|
||||
93
src/components/quick-settings-panel/constants.ts
Normal file
93
src/components/quick-settings-panel/constants.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
Brain,
|
||||
Eye,
|
||||
FileText,
|
||||
Languages,
|
||||
Maximize2,
|
||||
Mic,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import type {
|
||||
PreferenceToggleItem,
|
||||
WhisperMode,
|
||||
WhisperOption,
|
||||
} from './types';
|
||||
|
||||
export const HANDLE_POSITION_STORAGE_KEY = 'quickSettingsHandlePosition';
|
||||
export const WHISPER_MODE_STORAGE_KEY = 'whisperMode';
|
||||
export const WHISPER_MODE_CHANGED_EVENT = 'whisperModeChanged';
|
||||
|
||||
export const DEFAULT_HANDLE_POSITION = 50;
|
||||
export const HANDLE_POSITION_MIN = 10;
|
||||
export const HANDLE_POSITION_MAX = 90;
|
||||
export const DRAG_THRESHOLD_PX = 5;
|
||||
|
||||
export const SETTING_ROW_CLASS =
|
||||
'flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600';
|
||||
|
||||
export const TOGGLE_ROW_CLASS = `${SETTING_ROW_CLASS} cursor-pointer`;
|
||||
|
||||
export const CHECKBOX_CLASS =
|
||||
'h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600';
|
||||
|
||||
export const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [
|
||||
{
|
||||
key: 'autoExpandTools',
|
||||
labelKey: 'quickSettings.autoExpandTools',
|
||||
icon: Maximize2,
|
||||
},
|
||||
{
|
||||
key: 'showRawParameters',
|
||||
labelKey: 'quickSettings.showRawParameters',
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
key: 'showThinking',
|
||||
labelKey: 'quickSettings.showThinking',
|
||||
icon: Brain,
|
||||
},
|
||||
];
|
||||
|
||||
export const VIEW_OPTION_TOGGLES: PreferenceToggleItem[] = [
|
||||
{
|
||||
key: 'autoScrollToBottom',
|
||||
labelKey: 'quickSettings.autoScrollToBottom',
|
||||
icon: ArrowDown,
|
||||
},
|
||||
];
|
||||
|
||||
export const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [
|
||||
{
|
||||
key: 'sendByCtrlEnter',
|
||||
labelKey: 'quickSettings.sendByCtrlEnter',
|
||||
icon: Languages,
|
||||
},
|
||||
];
|
||||
|
||||
export const WHISPER_OPTIONS: WhisperOption[] = [
|
||||
{
|
||||
value: 'default',
|
||||
titleKey: 'quickSettings.whisper.modes.default',
|
||||
descriptionKey: 'quickSettings.whisper.modes.defaultDescription',
|
||||
icon: Mic,
|
||||
},
|
||||
{
|
||||
value: 'prompt',
|
||||
titleKey: 'quickSettings.whisper.modes.prompt',
|
||||
descriptionKey: 'quickSettings.whisper.modes.promptDescription',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
value: 'vibe',
|
||||
titleKey: 'quickSettings.whisper.modes.vibe',
|
||||
descriptionKey: 'quickSettings.whisper.modes.vibeDescription',
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
export const VIBE_MODE_ALIASES: WhisperMode[] = [
|
||||
'vibe',
|
||||
'instructions',
|
||||
'architect',
|
||||
];
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent } from 'react';
|
||||
import {
|
||||
DEFAULT_HANDLE_POSITION,
|
||||
DRAG_THRESHOLD_PX,
|
||||
HANDLE_POSITION_MAX,
|
||||
HANDLE_POSITION_MIN,
|
||||
HANDLE_POSITION_STORAGE_KEY,
|
||||
} from '../constants';
|
||||
import type { QuickSettingsHandleStyle } from '../types';
|
||||
|
||||
type UseQuickSettingsDragProps = {
|
||||
isMobile: boolean;
|
||||
};
|
||||
|
||||
type StartDragEvent = ReactMouseEvent<HTMLButtonElement> | ReactTouchEvent<HTMLButtonElement>;
|
||||
type MoveDragEvent = MouseEvent | TouchEvent;
|
||||
type EventWithClientY = StartDragEvent | MoveDragEvent;
|
||||
|
||||
const clampPosition = (value: number): number => (
|
||||
Math.max(HANDLE_POSITION_MIN, Math.min(HANDLE_POSITION_MAX, value))
|
||||
);
|
||||
|
||||
const readHandlePosition = (): number => {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_HANDLE_POSITION;
|
||||
}
|
||||
|
||||
const saved = localStorage.getItem(HANDLE_POSITION_STORAGE_KEY);
|
||||
if (!saved) {
|
||||
return DEFAULT_HANDLE_POSITION;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(saved) as { y?: unknown };
|
||||
if (typeof parsed.y === 'number' && Number.isFinite(parsed.y)) {
|
||||
return clampPosition(parsed.y);
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(HANDLE_POSITION_STORAGE_KEY);
|
||||
return DEFAULT_HANDLE_POSITION;
|
||||
}
|
||||
|
||||
return DEFAULT_HANDLE_POSITION;
|
||||
};
|
||||
|
||||
const isTouchEvent = (event: { type: string }): boolean => event.type.includes('touch');
|
||||
|
||||
const getClientY = (event: EventWithClientY): number | null => {
|
||||
if ('touches' in event) {
|
||||
return event.touches[0]?.clientY ?? null;
|
||||
}
|
||||
|
||||
return 'clientY' in event && typeof event.clientY === 'number'
|
||||
? event.clientY
|
||||
: null;
|
||||
};
|
||||
|
||||
export function useQuickSettingsDrag({ isMobile }: UseQuickSettingsDragProps) {
|
||||
const [handlePosition, setHandlePosition] = useState<number>(readHandlePosition);
|
||||
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const dragStartYRef = useRef<number | null>(null);
|
||||
const dragStartPositionRef = useRef(DEFAULT_HANDLE_POSITION);
|
||||
const didDragRef = useRef(false);
|
||||
const suppressNextClickRef = useRef(false);
|
||||
const bodyStylesAppliedRef = useRef(false);
|
||||
|
||||
const clearBodyDragStyles = useCallback(() => {
|
||||
if (!bodyStylesAppliedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
bodyStylesAppliedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const applyBodyDragStyles = useCallback((isTouchDragging: boolean) => {
|
||||
if (bodyStylesAppliedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
// Touch drag should lock body scroll so the handle movement stays smooth.
|
||||
if (isTouchDragging) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.width = '100%';
|
||||
}
|
||||
|
||||
bodyStylesAppliedRef.current = true;
|
||||
}, []);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
if (!isPointerDown && dragStartYRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
suppressNextClickRef.current = didDragRef.current;
|
||||
didDragRef.current = false;
|
||||
dragStartYRef.current = null;
|
||||
setIsPointerDown(false);
|
||||
setIsDragging(false);
|
||||
clearBodyDragStyles();
|
||||
}, [clearBodyDragStyles, isPointerDown]);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(event: MoveDragEvent) => {
|
||||
if (!isPointerDown || dragStartYRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientY = getClientY(event);
|
||||
if (clientY === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawDelta = clientY - dragStartYRef.current;
|
||||
const movedPastThreshold = Math.abs(rawDelta) > DRAG_THRESHOLD_PX;
|
||||
|
||||
if (!didDragRef.current && movedPastThreshold) {
|
||||
didDragRef.current = true;
|
||||
setIsDragging(true);
|
||||
applyBodyDragStyles(isTouchEvent(event));
|
||||
}
|
||||
|
||||
if (!didDragRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTouchEvent(event)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
const viewportHeight = Math.max(window.innerHeight, 1);
|
||||
const normalizedDelta = (rawDelta / viewportHeight) * 100;
|
||||
const positionDelta = isMobile ? -normalizedDelta : normalizedDelta;
|
||||
setHandlePosition(clampPosition(dragStartPositionRef.current + positionDelta));
|
||||
},
|
||||
[applyBodyDragStyles, isMobile, isPointerDown],
|
||||
);
|
||||
|
||||
const startDrag = useCallback((event: StartDragEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
const clientY = getClientY(event);
|
||||
if (clientY === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragStartYRef.current = clientY;
|
||||
dragStartPositionRef.current = handlePosition;
|
||||
didDragRef.current = false;
|
||||
setIsDragging(false);
|
||||
setIsPointerDown(true);
|
||||
}, [handlePosition]);
|
||||
|
||||
// Persist drag-handle position so users keep their preferred quick-access location.
|
||||
useEffect(() => {
|
||||
localStorage.setItem(
|
||||
HANDLE_POSITION_STORAGE_KEY,
|
||||
JSON.stringify({ y: handlePosition }),
|
||||
);
|
||||
}, [handlePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPointerDown) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
handleMove(event);
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
endDrag();
|
||||
};
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
handleMove(event);
|
||||
};
|
||||
const handleTouchEnd = () => {
|
||||
endDrag();
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}, [endDrag, handleMove, isPointerDown]);
|
||||
|
||||
useEffect(() => (
|
||||
() => {
|
||||
clearBodyDragStyles();
|
||||
}
|
||||
), [clearBodyDragStyles]);
|
||||
|
||||
const consumeSuppressedClick = useCallback((): boolean => {
|
||||
if (!suppressNextClickRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
suppressNextClickRef.current = false;
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const handleStyle = useMemo<QuickSettingsHandleStyle>(() => {
|
||||
if (!isMobile || typeof window === 'undefined') {
|
||||
return {
|
||||
top: `${handlePosition}%`,
|
||||
transform: 'translateY(-50%)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bottom: `${(window.innerHeight * handlePosition) / 100}px`,
|
||||
};
|
||||
}, [handlePosition, isMobile]);
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
handleStyle,
|
||||
startDrag,
|
||||
endDrag,
|
||||
consumeSuppressedClick,
|
||||
};
|
||||
}
|
||||
59
src/components/quick-settings-panel/hooks/useWhisperMode.ts
Normal file
59
src/components/quick-settings-panel/hooks/useWhisperMode.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
VIBE_MODE_ALIASES,
|
||||
WHISPER_MODE_CHANGED_EVENT,
|
||||
WHISPER_MODE_STORAGE_KEY,
|
||||
} from '../constants';
|
||||
import type { WhisperMode, WhisperOptionValue } from '../types';
|
||||
|
||||
const ALL_VALID_MODES: WhisperMode[] = [
|
||||
'default',
|
||||
'prompt',
|
||||
'vibe',
|
||||
'instructions',
|
||||
'architect',
|
||||
];
|
||||
|
||||
const isWhisperMode = (value: string): value is WhisperMode => (
|
||||
ALL_VALID_MODES.includes(value as WhisperMode)
|
||||
);
|
||||
|
||||
const readStoredMode = (): WhisperMode => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
const storedValue = localStorage.getItem(WHISPER_MODE_STORAGE_KEY);
|
||||
if (!storedValue) {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
return isWhisperMode(storedValue) ? storedValue : 'default';
|
||||
};
|
||||
|
||||
export function useWhisperMode() {
|
||||
const [whisperMode, setWhisperModeState] = useState<WhisperMode>(readStoredMode);
|
||||
|
||||
const setWhisperMode = useCallback((value: WhisperOptionValue) => {
|
||||
setWhisperModeState(value);
|
||||
localStorage.setItem(WHISPER_MODE_STORAGE_KEY, value);
|
||||
window.dispatchEvent(new Event(WHISPER_MODE_CHANGED_EVENT));
|
||||
}, []);
|
||||
|
||||
const isOptionSelected = useCallback(
|
||||
(value: WhisperOptionValue) => {
|
||||
if (value === 'vibe') {
|
||||
return VIBE_MODE_ALIASES.includes(whisperMode);
|
||||
}
|
||||
|
||||
return whisperMode === value;
|
||||
},
|
||||
[whisperMode],
|
||||
);
|
||||
|
||||
return {
|
||||
whisperMode,
|
||||
setWhisperMode,
|
||||
isOptionSelected,
|
||||
};
|
||||
}
|
||||
35
src/components/quick-settings-panel/types.ts
Normal file
35
src/components/quick-settings-panel/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export type PreferenceToggleKey =
|
||||
| 'autoExpandTools'
|
||||
| 'showRawParameters'
|
||||
| 'showThinking'
|
||||
| 'autoScrollToBottom'
|
||||
| 'sendByCtrlEnter';
|
||||
|
||||
export type QuickSettingsPreferences = Record<PreferenceToggleKey, boolean>;
|
||||
|
||||
export type PreferenceToggleItem = {
|
||||
key: PreferenceToggleKey;
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
export type WhisperMode =
|
||||
| 'default'
|
||||
| 'prompt'
|
||||
| 'vibe'
|
||||
| 'instructions'
|
||||
| 'architect';
|
||||
|
||||
export type WhisperOptionValue = 'default' | 'prompt' | 'vibe';
|
||||
|
||||
export type WhisperOption = {
|
||||
value: WhisperOptionValue;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
icon: LucideIcon;
|
||||
};
|
||||
|
||||
export type QuickSettingsHandleStyle = CSSProperties;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DarkModeToggle } from '../../../shared/view/ui';
|
||||
import LanguageSelector from '../../LanguageSelector.jsx';
|
||||
import {
|
||||
INPUT_SETTING_TOGGLES,
|
||||
SETTING_ROW_CLASS,
|
||||
TOOL_DISPLAY_TOGGLES,
|
||||
VIEW_OPTION_TOGGLES,
|
||||
} from '../constants';
|
||||
import type {
|
||||
PreferenceToggleItem,
|
||||
PreferenceToggleKey,
|
||||
QuickSettingsPreferences,
|
||||
} from '../types';
|
||||
import QuickSettingsSection from './QuickSettingsSection';
|
||||
import QuickSettingsToggleRow from './QuickSettingsToggleRow';
|
||||
import QuickSettingsWhisperSection from './QuickSettingsWhisperSection';
|
||||
|
||||
type QuickSettingsContentProps = {
|
||||
isDarkMode: boolean;
|
||||
isMobile: boolean;
|
||||
preferences: QuickSettingsPreferences;
|
||||
onPreferenceChange: (key: PreferenceToggleKey, value: boolean) => void;
|
||||
};
|
||||
|
||||
export default function QuickSettingsContent({
|
||||
isDarkMode,
|
||||
isMobile,
|
||||
preferences,
|
||||
onPreferenceChange,
|
||||
}: QuickSettingsContentProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
|
||||
const renderToggleRows = (items: PreferenceToggleItem[]) => (
|
||||
items.map(({ key, labelKey, icon }) => (
|
||||
<QuickSettingsToggleRow
|
||||
key={key}
|
||||
label={t(labelKey)}
|
||||
icon={icon}
|
||||
checked={preferences[key]}
|
||||
onCheckedChange={(value) => onPreferenceChange(key, value)}
|
||||
/>
|
||||
))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6 bg-background ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||
<QuickSettingsSection title={t('quickSettings.sections.appearance')}>
|
||||
<div className={SETTING_ROW_CLASS}>
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
{isDarkMode ? (
|
||||
<Moon className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
) : (
|
||||
<Sun className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
)}
|
||||
{t('quickSettings.darkMode')}
|
||||
</span>
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
<LanguageSelector compact />
|
||||
</QuickSettingsSection>
|
||||
|
||||
<QuickSettingsSection title={t('quickSettings.sections.toolDisplay')}>
|
||||
{renderToggleRows(TOOL_DISPLAY_TOGGLES)}
|
||||
</QuickSettingsSection>
|
||||
|
||||
<QuickSettingsSection title={t('quickSettings.sections.viewOptions')}>
|
||||
{renderToggleRows(VIEW_OPTION_TOGGLES)}
|
||||
</QuickSettingsSection>
|
||||
|
||||
<QuickSettingsSection title={t('quickSettings.sections.inputSettings')}>
|
||||
{renderToggleRows(INPUT_SETTING_TOGGLES)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 ml-3">
|
||||
{t('quickSettings.sendByCtrlEnterDescription')}
|
||||
</p>
|
||||
</QuickSettingsSection>
|
||||
|
||||
<QuickSettingsWhisperSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
TouchEvent as ReactTouchEvent,
|
||||
} from 'react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
GripVertical,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { QuickSettingsHandleStyle } from '../types';
|
||||
|
||||
type QuickSettingsHandleProps = {
|
||||
isOpen: boolean;
|
||||
isDragging: boolean;
|
||||
style: QuickSettingsHandleStyle;
|
||||
onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void;
|
||||
onMouseDown: (event: ReactMouseEvent<HTMLButtonElement>) => void;
|
||||
onTouchStart: (event: ReactTouchEvent<HTMLButtonElement>) => void;
|
||||
};
|
||||
|
||||
export default function QuickSettingsHandle({
|
||||
isOpen,
|
||||
isDragging,
|
||||
style,
|
||||
onClick,
|
||||
onMouseDown,
|
||||
onTouchStart,
|
||||
}: QuickSettingsHandleProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
|
||||
const placementClass = isOpen ? 'right-64' : 'right-0';
|
||||
const borderClass = isDragging
|
||||
? 'border-blue-500 dark:border-blue-400'
|
||||
: 'border-gray-200 dark:border-gray-700';
|
||||
const transitionClass = isDragging
|
||||
? ''
|
||||
: 'transition-all duration-150 ease-out';
|
||||
const cursorClass = isDragging ? 'cursor-grabbing' : 'cursor-pointer';
|
||||
const ariaLabel = isDragging
|
||||
? t('quickSettings.dragHandle.dragging')
|
||||
: isOpen
|
||||
? t('quickSettings.dragHandle.closePanel')
|
||||
: t('quickSettings.dragHandle.openPanel');
|
||||
const title = isDragging
|
||||
? t('quickSettings.dragHandle.draggingStatus')
|
||||
: t('quickSettings.dragHandle.toggleAndMove');
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
className={`fixed ${placementClass} z-50 ${transitionClass} bg-white dark:bg-gray-800 border ${borderClass} rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg ${cursorClass} touch-none`}
|
||||
style={{
|
||||
...style,
|
||||
touchAction: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
}}
|
||||
aria-label={ariaLabel}
|
||||
title={title}
|
||||
>
|
||||
{isDragging ? (
|
||||
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
|
||||
) : isOpen ? (
|
||||
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
) : (
|
||||
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function QuickSettingsPanelHeader() {
|
||||
const { t } = useTranslation('settings');
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
{t('quickSettings.title')}
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { useDeviceSettings } from '../../../hooks/useDeviceSettings';
|
||||
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useQuickSettingsDrag } from '../hooks/useQuickSettingsDrag';
|
||||
import type { PreferenceToggleKey, QuickSettingsPreferences } from '../types';
|
||||
import QuickSettingsContent from './QuickSettingsContent';
|
||||
import QuickSettingsHandle from './QuickSettingsHandle';
|
||||
import QuickSettingsPanelHeader from './QuickSettingsPanelHeader';
|
||||
|
||||
export default function QuickSettingsPanelView() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
||||
const { isDarkMode } = useTheme();
|
||||
const { preferences, setPreference } = useUiPreferences();
|
||||
const {
|
||||
isDragging,
|
||||
handleStyle,
|
||||
startDrag,
|
||||
consumeSuppressedClick,
|
||||
} = useQuickSettingsDrag({ isMobile });
|
||||
|
||||
const quickSettingsPreferences = useMemo<QuickSettingsPreferences>(() => ({
|
||||
autoExpandTools: preferences.autoExpandTools,
|
||||
showRawParameters: preferences.showRawParameters,
|
||||
showThinking: preferences.showThinking,
|
||||
autoScrollToBottom: preferences.autoScrollToBottom,
|
||||
sendByCtrlEnter: preferences.sendByCtrlEnter,
|
||||
}), [
|
||||
preferences.autoExpandTools,
|
||||
preferences.autoScrollToBottom,
|
||||
preferences.sendByCtrlEnter,
|
||||
preferences.showRawParameters,
|
||||
preferences.showThinking,
|
||||
]);
|
||||
|
||||
const handlePreferenceChange = useCallback(
|
||||
(key: PreferenceToggleKey, value: boolean) => {
|
||||
setPreference(key, value);
|
||||
},
|
||||
[setPreference],
|
||||
);
|
||||
|
||||
const handleToggleFromHandle = useCallback(
|
||||
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
// A drag releases a click event as well; this guard prevents accidental toggles.
|
||||
if (consumeSuppressedClick()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen((previous) => !previous);
|
||||
},
|
||||
[consumeSuppressedClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<QuickSettingsHandle
|
||||
isOpen={isOpen}
|
||||
isDragging={isDragging}
|
||||
style={handleStyle}
|
||||
onClick={handleToggleFromHandle}
|
||||
onMouseDown={startDrag}
|
||||
onTouchStart={startDrag}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-64 bg-background border-l border-border shadow-xl transform transition-transform duration-150 ease-out z-40 ${isOpen ? 'translate-x-0' : 'translate-x-full'} ${isMobile ? 'h-screen' : ''}`}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
<QuickSettingsPanelHeader />
|
||||
<QuickSettingsContent
|
||||
isDarkMode={isDarkMode}
|
||||
isMobile={isMobile}
|
||||
preferences={quickSettingsPreferences}
|
||||
onPreferenceChange={handlePreferenceChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-30 transition-opacity duration-150 ease-out"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type QuickSettingsSectionProps = {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function QuickSettingsSection({
|
||||
title,
|
||||
children,
|
||||
className = '',
|
||||
}: QuickSettingsSectionProps) {
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">
|
||||
{title}
|
||||
</h4>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { memo } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { CHECKBOX_CLASS, TOGGLE_ROW_CLASS } from '../constants';
|
||||
|
||||
type QuickSettingsToggleRowProps = {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
function QuickSettingsToggleRow({
|
||||
label,
|
||||
icon: Icon,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: QuickSettingsToggleRowProps) {
|
||||
return (
|
||||
<label className={TOGGLE_ROW_CLASS}>
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Icon className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => onCheckedChange(event.target.checked)}
|
||||
className={CHECKBOX_CLASS}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(QuickSettingsToggleRow);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TOGGLE_ROW_CLASS, WHISPER_OPTIONS } from '../constants';
|
||||
import { useWhisperMode } from '../hooks/useWhisperMode';
|
||||
import QuickSettingsSection from './QuickSettingsSection';
|
||||
|
||||
export default function QuickSettingsWhisperSection() {
|
||||
const { t } = useTranslation('settings');
|
||||
const { setWhisperMode, isOptionSelected } = useWhisperMode();
|
||||
|
||||
return (
|
||||
// This section stays hidden intentionally until dictation modes are reintroduced.
|
||||
<QuickSettingsSection
|
||||
title={t('quickSettings.sections.whisperDictation')}
|
||||
className="hidden"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{WHISPER_OPTIONS.map(({ value, icon: Icon, titleKey, descriptionKey }) => (
|
||||
<label
|
||||
key={value}
|
||||
className={`${TOGGLE_ROW_CLASS} flex items-start`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="whisperMode"
|
||||
value={value}
|
||||
checked={isOptionSelected(value)}
|
||||
onChange={() => setWhisperMode(value)}
|
||||
className="mt-0.5 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
/>
|
||||
<div className="ml-3 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<Icon className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
{t(titleKey)}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t(descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</QuickSettingsSection>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user