From 2492eb10521208719477d3791471a051aa085e06 Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Mon, 2 Mar 2026 16:43:55 +0300 Subject: [PATCH] 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 --- src/components/QuickSettingsPanel.jsx | 448 ------------------ src/components/QuickSettingsPanel.tsx | 5 + .../quick-settings-panel/constants.ts | 93 ++++ .../hooks/useQuickSettingsDrag.ts | 239 ++++++++++ .../hooks/useWhisperMode.ts | 59 +++ src/components/quick-settings-panel/types.ts | 35 ++ .../view/QuickSettingsContent.tsx | 82 ++++ .../view/QuickSettingsHandle.tsx | 74 +++ .../view/QuickSettingsPanelHeader.tsx | 15 + .../view/QuickSettingsPanelView.tsx | 91 ++++ .../view/QuickSettingsSection.tsx | 22 + .../view/QuickSettingsToggleRow.tsx | 34 ++ .../view/QuickSettingsWhisperSection.tsx | 44 ++ 13 files changed, 793 insertions(+), 448 deletions(-) delete mode 100644 src/components/QuickSettingsPanel.jsx create mode 100644 src/components/QuickSettingsPanel.tsx create mode 100644 src/components/quick-settings-panel/constants.ts create mode 100644 src/components/quick-settings-panel/hooks/useQuickSettingsDrag.ts create mode 100644 src/components/quick-settings-panel/hooks/useWhisperMode.ts create mode 100644 src/components/quick-settings-panel/types.ts create mode 100644 src/components/quick-settings-panel/view/QuickSettingsContent.tsx create mode 100644 src/components/quick-settings-panel/view/QuickSettingsHandle.tsx create mode 100644 src/components/quick-settings-panel/view/QuickSettingsPanelHeader.tsx create mode 100644 src/components/quick-settings-panel/view/QuickSettingsPanelView.tsx create mode 100644 src/components/quick-settings-panel/view/QuickSettingsSection.tsx create mode 100644 src/components/quick-settings-panel/view/QuickSettingsToggleRow.tsx create mode 100644 src/components/quick-settings-panel/view/QuickSettingsWhisperSection.tsx diff --git a/src/components/QuickSettingsPanel.jsx b/src/components/QuickSettingsPanel.jsx deleted file mode 100644 index 5dd57284..00000000 --- a/src/components/QuickSettingsPanel.jsx +++ /dev/null @@ -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 */} - - - {/* Panel */} -
-
- {/* Header */} -
-

- - {t('quickSettings.title')} -

-
- - {/* Settings Content */} -
- {/* Appearance Settings */} -
-

{t('quickSettings.sections.appearance')}

- -
- - {isDarkMode ? : } - {t('quickSettings.darkMode')} - - -
- - {/* Language Selector */} -
- -
-
- - {/* Tool Display Settings */} -
-

{t('quickSettings.sections.toolDisplay')}

- - - - - - -
- {/* View Options */} -
-

{t('quickSettings.sections.viewOptions')}

- - -
- - {/* Input Settings */} -
-

{t('quickSettings.sections.inputSettings')}

- - -

- {t('quickSettings.sendByCtrlEnterDescription')} -

-
- - {/* Whisper Dictation Settings - HIDDEN */} -
-

{t('quickSettings.sections.whisperDictation')}

- -
- - - - - -
-
-
-
-
- - {/* Backdrop */} - {isOpen && ( -
- )} - - ); -}; - -export default QuickSettingsPanel; diff --git a/src/components/QuickSettingsPanel.tsx b/src/components/QuickSettingsPanel.tsx new file mode 100644 index 00000000..564628fe --- /dev/null +++ b/src/components/QuickSettingsPanel.tsx @@ -0,0 +1,5 @@ +import QuickSettingsPanelView from './quick-settings-panel/view/QuickSettingsPanelView'; + +export default function QuickSettingsPanel() { + return ; +} diff --git a/src/components/quick-settings-panel/constants.ts b/src/components/quick-settings-panel/constants.ts new file mode 100644 index 00000000..5f1a8e21 --- /dev/null +++ b/src/components/quick-settings-panel/constants.ts @@ -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', +]; diff --git a/src/components/quick-settings-panel/hooks/useQuickSettingsDrag.ts b/src/components/quick-settings-panel/hooks/useQuickSettingsDrag.ts new file mode 100644 index 00000000..1913d668 --- /dev/null +++ b/src/components/quick-settings-panel/hooks/useQuickSettingsDrag.ts @@ -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 | ReactTouchEvent; +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(readHandlePosition); + const [isPointerDown, setIsPointerDown] = useState(false); + const [isDragging, setIsDragging] = useState(false); + + const dragStartYRef = useRef(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(() => { + 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, + }; +} diff --git a/src/components/quick-settings-panel/hooks/useWhisperMode.ts b/src/components/quick-settings-panel/hooks/useWhisperMode.ts new file mode 100644 index 00000000..eeda67ce --- /dev/null +++ b/src/components/quick-settings-panel/hooks/useWhisperMode.ts @@ -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(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, + }; +} diff --git a/src/components/quick-settings-panel/types.ts b/src/components/quick-settings-panel/types.ts new file mode 100644 index 00000000..4a12fc01 --- /dev/null +++ b/src/components/quick-settings-panel/types.ts @@ -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; + +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; diff --git a/src/components/quick-settings-panel/view/QuickSettingsContent.tsx b/src/components/quick-settings-panel/view/QuickSettingsContent.tsx new file mode 100644 index 00000000..3075716b --- /dev/null +++ b/src/components/quick-settings-panel/view/QuickSettingsContent.tsx @@ -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 }) => ( + onPreferenceChange(key, value)} + /> + )) + ); + + return ( +
+ +
+ + {isDarkMode ? ( + + ) : ( + + )} + {t('quickSettings.darkMode')} + + +
+ +
+ + + {renderToggleRows(TOOL_DISPLAY_TOGGLES)} + + + + {renderToggleRows(VIEW_OPTION_TOGGLES)} + + + + {renderToggleRows(INPUT_SETTING_TOGGLES)} +

+ {t('quickSettings.sendByCtrlEnterDescription')} +

+
+ + +
+ ); +} diff --git a/src/components/quick-settings-panel/view/QuickSettingsHandle.tsx b/src/components/quick-settings-panel/view/QuickSettingsHandle.tsx new file mode 100644 index 00000000..98141479 --- /dev/null +++ b/src/components/quick-settings-panel/view/QuickSettingsHandle.tsx @@ -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) => void; + onMouseDown: (event: ReactMouseEvent) => void; + onTouchStart: (event: ReactTouchEvent) => 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 ( + + ); +} diff --git a/src/components/quick-settings-panel/view/QuickSettingsPanelHeader.tsx b/src/components/quick-settings-panel/view/QuickSettingsPanelHeader.tsx new file mode 100644 index 00000000..e24b75b6 --- /dev/null +++ b/src/components/quick-settings-panel/view/QuickSettingsPanelHeader.tsx @@ -0,0 +1,15 @@ +import { Settings2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +export default function QuickSettingsPanelHeader() { + const { t } = useTranslation('settings'); + + return ( +
+

+ + {t('quickSettings.title')} +

+
+ ); +} diff --git a/src/components/quick-settings-panel/view/QuickSettingsPanelView.tsx b/src/components/quick-settings-panel/view/QuickSettingsPanelView.tsx new file mode 100644 index 00000000..17b26ff9 --- /dev/null +++ b/src/components/quick-settings-panel/view/QuickSettingsPanelView.tsx @@ -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(() => ({ + 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) => { + // A drag releases a click event as well; this guard prevents accidental toggles. + if (consumeSuppressedClick()) { + event.preventDefault(); + return; + } + + setIsOpen((previous) => !previous); + }, + [consumeSuppressedClick], + ); + + return ( + <> + + +
+
+ + +
+
+ + {isOpen && ( +
setIsOpen(false)} + /> + )} + + ); +} diff --git a/src/components/quick-settings-panel/view/QuickSettingsSection.tsx b/src/components/quick-settings-panel/view/QuickSettingsSection.tsx new file mode 100644 index 00000000..07531bec --- /dev/null +++ b/src/components/quick-settings-panel/view/QuickSettingsSection.tsx @@ -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 ( +
+

+ {title} +

+ {children} +
+ ); +} diff --git a/src/components/quick-settings-panel/view/QuickSettingsToggleRow.tsx b/src/components/quick-settings-panel/view/QuickSettingsToggleRow.tsx new file mode 100644 index 00000000..61bdd52c --- /dev/null +++ b/src/components/quick-settings-panel/view/QuickSettingsToggleRow.tsx @@ -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 ( + + ); +} + +export default memo(QuickSettingsToggleRow); diff --git a/src/components/quick-settings-panel/view/QuickSettingsWhisperSection.tsx b/src/components/quick-settings-panel/view/QuickSettingsWhisperSection.tsx new file mode 100644 index 00000000..297dbdb6 --- /dev/null +++ b/src/components/quick-settings-panel/view/QuickSettingsWhisperSection.tsx @@ -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. + +
+ {WHISPER_OPTIONS.map(({ value, icon: Icon, titleKey, descriptionKey }) => ( + + ))} +
+
+ ); +}