From dcf94c6abe0c656bdfeac140c9124eec2d218902 Mon Sep 17 00:00:00 2001 From: spencrmartin Date: Tue, 13 Jan 2026 13:38:45 -0500 Subject: [PATCH] feat: Complete navigation system with customizable layouts and widgets Add comprehensive navigation system with two modes (push/overlay), four positions, three interactive widgets, and full customization capabilities. Components: - TopNavigation: Expanded tile-based layout with auto-fit grid - CondensedNavigation: Compact row-based layout - NavigationCustomizationSettings: Preferences management - Navigation mode/position/style selectors Features: - Two navigation modes: push (sidebar) and overlay (launcher) - Four positions: top, bottom, left, right - Two styles: expanded (tiles) and condensed (rows) - Three widgets: analog clock, activity heatmap, token counter - Drag & drop reordering of navigation items - Show/hide controls for navigation items - Comprehensive settings UI with collapsible sections - Persistent user preferences in localStorage - Settings tab state persistence across mode changes - Centered overlay mode (always expanded) - Real-time stats and live data updates Technical: - Added custom CSS animations to main.css (nav-tile, nav-overlay-enter, etc.) - Components use framer-motion for animations (needs manual refactor to CSS) - All TypeScript types properly defined - Settings integration complete Note: The navigation components currently import framer-motion for animations. These imports need to be manually replaced with the CSS animations prepared in main.css. Automated refactoring broke JSX structure in testing. TODO: Replace framer-motion imports with CSS-based animations before merge. --- .../src/components/Layout/AppLayout.tsx | 346 +++++-- .../components/Layout/CondensedNavigation.tsx | 885 ++++++++++++++++++ .../src/components/Layout/TopNavigation.tsx | 688 ++++++++++++++ .../src/components/settings/SettingsView.tsx | 12 +- .../settings/app/AppSettingsSection.tsx | 87 +- .../app/NavigationCustomizationSettings.tsx | 193 ++++ .../settings/app/NavigationModeSelector.tsx | 96 ++ .../app/NavigationPositionSelector.tsx | 71 ++ .../settings/app/NavigationStyleSelector.tsx | 66 ++ ui/desktop/src/styles/main.css | 117 +++ 10 files changed, 2467 insertions(+), 94 deletions(-) create mode 100644 ui/desktop/src/components/Layout/CondensedNavigation.tsx create mode 100644 ui/desktop/src/components/Layout/TopNavigation.tsx create mode 100644 ui/desktop/src/components/settings/app/NavigationCustomizationSettings.tsx create mode 100644 ui/desktop/src/components/settings/app/NavigationModeSelector.tsx create mode 100644 ui/desktop/src/components/settings/app/NavigationPositionSelector.tsx create mode 100644 ui/desktop/src/components/settings/app/NavigationStyleSelector.tsx diff --git a/ui/desktop/src/components/Layout/AppLayout.tsx b/ui/desktop/src/components/Layout/AppLayout.tsx index d801fa86ce..54897152c7 100644 --- a/ui/desktop/src/components/Layout/AppLayout.tsx +++ b/ui/desktop/src/components/Layout/AppLayout.tsx @@ -1,105 +1,267 @@ -import React from 'react'; -import { Outlet, useNavigate, useLocation } from 'react-router-dom'; -import AppSidebar from '../GooseSidebar/AppSidebar'; -import { View, ViewOptions } from '../../utils/navigationUtils'; -import { AppWindowMac, AppWindow } from 'lucide-react'; +import React, { useState, createContext, useContext, useEffect } from 'react'; +import { Outlet } from 'react-router-dom'; +import { AppWindowMac, AppWindow, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react'; import { Button } from '../ui/button'; -import { Sidebar, SidebarInset, SidebarProvider, SidebarTrigger, useSidebar } from '../ui/sidebar'; -import { getInitialWorkingDir } from '../../utils/workingDir'; +import { SidebarProvider } from '../ui/sidebar'; +import { TopNavigation } from './TopNavigation'; +import { CondensedNavigation } from './CondensedNavigation'; +import { NavigationPosition } from '../settings/app/NavigationPositionSelector'; +import { NavigationStyle } from '../settings/app/NavigationStyleSelector'; +import { NavigationMode } from '../settings/app/NavigationModeSelector'; -const AppLayoutContent: React.FC = () => { - const navigate = useNavigate(); - const location = useLocation(); +// Create context for navigation state +const NavigationContext = createContext<{ + isNavExpanded: boolean; + setIsNavExpanded: (expanded: boolean) => void; + navigationPosition: NavigationPosition; +}>({ + isNavExpanded: false, + setIsNavExpanded: () => {}, + navigationPosition: 'top' +}); + +export const useNavigation = () => useContext(NavigationContext); + +interface AppLayoutProps { + setIsGoosehintsModalOpen?: (isOpen: boolean) => void; +} + +// Inner component +const AppLayoutContent: React.FC = () => { const safeIsMacOS = (window?.electron?.platform || 'darwin') === 'darwin'; - const { isMobile, openMobile } = useSidebar(); - - // Calculate padding based on sidebar state and macOS - const headerPadding = safeIsMacOS ? 'pl-21' : 'pl-4'; - // const headerPadding = ''; - - // Hide buttons when mobile sheet is showing - const shouldHideButtons = isMobile && openMobile; - - const setView = (view: View, viewOptions?: ViewOptions) => { - // Convert view-based navigation to route-based navigation - switch (view) { - case 'chat': - navigate('/'); - break; - case 'pair': - navigate('/pair'); - break; - case 'settings': - navigate('/settings', { state: viewOptions }); - break; - case 'extensions': - navigate('/extensions', { state: viewOptions }); - break; - case 'sessions': - navigate('/sessions'); - break; - case 'schedules': - navigate('/schedules'); - break; - case 'recipes': - navigate('/recipes'); - break; - case 'permission': - navigate('/permission', { state: viewOptions }); - break; - case 'ConfigureProviders': - navigate('/configure-providers'); - break; - case 'sharedSession': - navigate('/shared-session', { state: viewOptions }); - break; - case 'welcome': - navigate('/welcome'); - break; - default: - navigate('/'); - } - }; - - const handleSelectSession = async (sessionId: string) => { - // Navigate to chat with session data - navigate('/', { state: { sessionId } }); - }; + const [isNavExpanded, setIsNavExpanded] = useState(false); + + const [navigationPosition, setNavigationPosition] = useState(() => { + const stored = localStorage.getItem('navigation_position'); + return (stored as NavigationPosition) || 'top'; + }); + const [navigationStyle, setNavigationStyle] = useState(() => { + const stored = localStorage.getItem('navigation_style'); + return (stored as NavigationStyle) || 'expanded'; + }); + const [navigationMode, setNavigationMode] = useState(() => { + const stored = localStorage.getItem('navigation_mode'); + return (stored as NavigationMode) || 'push'; + }); + + // Listen for navigation position changes + useEffect(() => { + const handlePositionChange = (e: Event) => { + const customEvent = e as CustomEvent<{ position: NavigationPosition }>; + setNavigationPosition(customEvent.detail.position); + }; + + window.addEventListener('navigation-position-changed', handlePositionChange); + return () => window.removeEventListener('navigation-position-changed', handlePositionChange); + }, []); + + // Listen for navigation style changes + useEffect(() => { + const handleStyleChange = (e: Event) => { + const customEvent = e as CustomEvent<{ style: NavigationStyle }>; + setNavigationStyle(customEvent.detail.style); + }; + + window.addEventListener('navigation-style-changed', handleStyleChange); + return () => window.removeEventListener('navigation-style-changed', handleStyleChange); + }, []); + + // Listen for navigation mode changes + useEffect(() => { + const handleModeChange = (e: Event) => { + const customEvent = e as CustomEvent<{ mode: NavigationMode }>; + setNavigationMode(customEvent.detail.mode); + }; + + window.addEventListener('navigation-mode-changed', handleModeChange); + return () => window.removeEventListener('navigation-mode-changed', handleModeChange); + }, []); const handleNewWindow = () => { - window.electron.createChatWindow(undefined, getInitialWorkingDir()); + window.electron.createChatWindow( + undefined, + window.appConfig.get('GOOSE_WORKING_DIR') as string | undefined + ); }; - return ( -
- {!shouldHideButtons && ( -
- - -
- )} - - - - + // Determine layout direction based on navigation position (only for push mode) + const isHorizontalNav = navigationPosition === 'top' || navigationPosition === 'bottom'; + const flexDirection = isHorizontalNav ? 'flex-col' : 'flex-row'; + + // Render the main content area + const mainContent = ( +
+
- +
); + + // Render navigation component based on style + const navigationComponent = navigationStyle === 'expanded' ? ( + + ) : ( + + ); + + // Overlay navigation component (full screen) + const overlayNavigationComponent = ( +
+ {/* Overlay background with blur - click to close */} + {isNavExpanded && ( +
setIsNavExpanded(false)} + /> + )} + + {/* Navigation overlay - Full screen without container */} +
+ {/* Navigation content - let TopNavigation handle positioning */} + {navigationComponent} +
+
+ ); + + return ( + +
+ + {navigationMode === 'overlay' ? ( + // Overlay Mode - Full screen content with floating navigation +
+ {/* Main Content Area - Full Screen */} + {mainContent} + + {/* Overlay Navigation - Only show when expanded */} + {overlayNavigationComponent} + + {/* Control Buttons - Position based on navigation position setting */} +
+ + +
+
+ ) : ( + // Push Mode - Traditional layout with positioned navigation +
+ {/* Navigation placement based on position - always render but let component handle visibility */} + {navigationPosition === 'top' && ( +
+ {navigationComponent} +
+ )} + {navigationPosition === 'left' && ( +
+ {navigationComponent} +
+ )} + + {/* Main Content Area */} + {mainContent} + + {/* Navigation placement for bottom and right - always render but let component handle visibility */} + {navigationPosition === 'bottom' && ( +
+ {navigationComponent} +
+ )} + {navigationPosition === 'right' && ( +
+ {navigationComponent} +
+ )} + + {/* Control Buttons - position based on nav location */} +
+ + +
+
+ )} +
+
+ ); }; export const AppLayout: React.FC = () => { diff --git a/ui/desktop/src/components/Layout/CondensedNavigation.tsx b/ui/desktop/src/components/Layout/CondensedNavigation.tsx new file mode 100644 index 0000000000..aab65f31a6 --- /dev/null +++ b/ui/desktop/src/components/Layout/CondensedNavigation.tsx @@ -0,0 +1,885 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { Home, History, FileText, Puzzle, Settings as SettingsIcon, GripVertical, Users, Hash, ShoppingBag } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { ChatSmart } from '../icons'; +import { listSessions, getSessionInsights } from '../../api'; +import { useConfig } from '../ConfigContext'; +import { listSavedRecipes } from '../../recipe/recipe_management'; +import { useNavigationCustomization } from '../settings/app/NavigationCustomizationSettings'; + +interface NavItem { + id: string; + path?: string; + label: string; + icon?: React.ComponentType<{ className?: string }>; + getTag?: () => string; + tagAlign?: 'left' | 'right'; + isWidget?: boolean; + renderContent?: () => React.ReactNode; +} + +export type NavigationPosition = 'top' | 'bottom' | 'left' | 'right'; + +interface CondensedNavigationProps { + isExpanded: boolean; + setIsExpanded: (expanded: boolean) => void; + position?: NavigationPosition; + isOverlayMode?: boolean; +} + +export const CondensedNavigation: React.FC = ({ + isExpanded, + setIsExpanded, + position = 'top', + isOverlayMode = false +}) => { + const navigate = useNavigate(); + const location = useLocation(); + const { extensionsList, getExtensions } = useConfig(); + const { preferences } = useNavigationCustomization(); + const [forceUpdate, setForceUpdate] = useState(0); + const [currentTime, setCurrentTime] = useState(''); + const [todayChatsCount, setTodayChatsCount] = useState(0); + const [totalSessions, setTotalSessions] = useState(0); + const [recipesCount, setRecipesCount] = useState(0); + const [scheduledTodayCount, setScheduledTodayCount] = useState(0); + const [totalTokens, setTotalTokens] = useState(0); + + // Handle escape key to close overlay + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && isExpanded && isOverlayMode) { + event.preventDefault(); + event.stopPropagation(); + setIsExpanded(false); + } + }; + + if (isOverlayMode && isExpanded) { + document.addEventListener('keydown', handleKeyDown, { capture: true }); + return () => document.removeEventListener('keydown', handleKeyDown, { capture: true }); + } + return undefined; + }, [isExpanded, isOverlayMode, setIsExpanded]); + + const [sessionHeatmapData, setSessionHeatmapData] = useState>({}); + + // Track previous values for pulse animation + const [prevValues, setPrevValues] = useState>({}); + const [pulsingItems, setPulsingItems] = useState>(new Set()); + + // Drag and drop state + const [draggedItem, setDraggedItem] = useState(null); + const [dragOverItem, setDragOverItem] = useState(null); + const [itemOrder, setItemOrder] = useState([]); + + // Update time every second + useEffect(() => { + const updateTime = () => { + const now = new Date(); + setCurrentTime(now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })); + }; + updateTime(); + const interval = setInterval(updateTime, 1000); + return () => clearInterval(interval); + }, []); + + // Fetch data when navigation expands + useEffect(() => { + if (isExpanded) { + fetchNavigationData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isExpanded]); + + const fetchNavigationData = async () => { + try { + // Fetch today's chats and build heatmap + const sessionsResponse = await listSessions({ throwOnError: false }); + if (sessionsResponse.data) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Build heatmap data for last 30 days and count today's chats in one pass + const heatmap: Record = {}; + let todayCount = 0; + + sessionsResponse.data.sessions.forEach((session) => { + const sessionDate = new Date(session.created_at); + const dateKey = sessionDate.toISOString().split('T')[0]; + heatmap[dateKey] = (heatmap[dateKey] || 0) + 1; + + // Count today's chats + const sessionDateOnly = new Date(session.created_at); + sessionDateOnly.setHours(0, 0, 0, 0); + if (sessionDateOnly.getTime() === today.getTime()) { + todayCount++; + } + }); + + setSessionHeatmapData(heatmap); + setTodayChatsCount(todayCount); + } + + // Fetch total sessions and tokens + const insightsResponse = await getSessionInsights({ throwOnError: false }); + if (insightsResponse.data) { + setTotalSessions(insightsResponse.data.totalSessions || 0); + setTotalTokens(insightsResponse.data.totalTokens || 0); + } + + // Fetch recipes count + try { + const recipes = await listSavedRecipes(); + setRecipesCount(recipes.length); + } catch (error) { + console.error('Failed to fetch recipes:', error); + setRecipesCount(0); + } + + // TODO: Fetch scheduled jobs when API is available + setScheduledTodayCount(0); + + // Refresh extensions data + try { + await getExtensions(true); + } catch (error) { + console.error('Failed to fetch extensions:', error); + } + } catch (error) { + console.error('Failed to fetch navigation data:', error); + } + }; + + // Analog Clock Widget Component + const AnalogClock: React.FC = () => { + const [secondAngle, setSecondAngle] = useState(() => { + const now = new Date(); + return now.getSeconds() * 6; + }); + const [angles, setAngles] = useState(() => { + const now = new Date(); + const hours = now.getHours() % 12; + const minutes = now.getMinutes(); + return { + hour: (hours * 30) + (minutes * 0.5), + minute: minutes * 6, + }; + }); + + useEffect(() => { + const interval = setInterval(() => { + const now = new Date(); + const hours = now.getHours() % 12; + const minutes = now.getMinutes(); + + // Increment second angle by 6 degrees each second + setSecondAngle(prev => prev + 6); + + setAngles({ + hour: (hours * 30) + (minutes * 0.5), + minute: minutes * 6, + }); + }, 1000); + return () => clearInterval(interval); + }, []); + + const hourAngle = angles.hour; + const minuteAngle = angles.minute; + + return ( +
+
+ {/* Hour markers */} + {[...Array(12)].map((_, i) => ( +
+ ))} + + {/* Hour hand */} +
+ + {/* Minute hand */} +
+ + {/* Second hand */} +
+ + {/* Center dot */} +
+
+
+ ); + }; + + const navItemsBase: NavItem[] = [ + { + id: 'home', + path: '/', + label: 'Home', + icon: Home, + getTag: () => currentTime, + }, + { + id: 'chat', + path: '/pair', + label: 'Chat', + icon: ChatSmart, + getTag: () => `${todayChatsCount}`, + tagAlign: 'left', + }, + { + id: 'history', + path: '/sessions', + label: 'History', + icon: History, + getTag: () => `${totalSessions}`, + tagAlign: 'left', + }, + { + id: 'recipes', + path: '/recipes', + label: 'Recipes', + icon: FileText, + getTag: () => `${recipesCount}`, + }, + { + id: 'scheduler', + path: '/schedules', + label: 'Marketplace', + icon: ShoppingBag, + }, + { + id: 'extensions', + path: '/extensions', + label: 'Extensions', + icon: Puzzle, + getTag: () => { + if (!extensionsList || !Array.isArray(extensionsList)) { + return '0/0'; + } + const enabled = extensionsList.filter(ext => ext.enabled).length; + const total = extensionsList.length; + return `${enabled}/${total}`; + }, + }, + { + id: 'peers', + path: '/peers', + label: 'Peers', + icon: Users, + getTag: () => '3', + tagAlign: 'left', + }, + { + id: 'channels', + path: '/channels', + label: 'Channels', + icon: Hash, + getTag: () => '7', + tagAlign: 'left', + }, + { + id: 'settings', + path: '/settings', + label: 'Settings', + icon: SettingsIcon, + }, + // Widget tiles for overlay mode + { + id: 'clock-widget', + label: 'Clock', + isWidget: true, + renderContent: () => , + }, + { + id: 'activity-widget', + label: 'Activity', + isWidget: true, + renderContent: () => { + const days = 35; + const today = new Date(); + const heatmapCells = []; + + for (let i = days - 1; i >= 0; i--) { + const date = new Date(today); + date.setDate(date.getDate() - i); + const dateKey = date.toISOString().split('T')[0]; + const count = sessionHeatmapData[dateKey] || 0; + + const maxCount = Math.max(...Object.values(sessionHeatmapData), 1); + const intensity = count === 0 ? 0 : Math.ceil((count / maxCount) * 4); + + heatmapCells.push({ date: dateKey, count, intensity }); + } + + return ( +
+
+ {heatmapCells.map((cell, index) => ( +
+ ))} +
+
+ Last 35 days +
+
+ ); + }, + }, + { + id: 'tokens-widget', + label: 'Tokens', + isWidget: true, + renderContent: () => { + const tokensInMillions = (totalTokens / 1000000).toFixed(2); + + return ( +
+
+
+ {tokensInMillions}M +
+
+ Total tokens +
+
+
+ ); + }, + }, + ]; + + // Filter and order navigation items based on user preferences + const navItems = React.useMemo(() => { + console.log('CondensedNavigation: Recomputing navItems with preferences:', preferences); + + // Filter enabled items + const enabledItems = navItemsBase.filter(item => + preferences.enabledItems.includes(item.id) + ); + + // Filter widgets based on overlay mode + const filteredItems = enabledItems.filter(item => + isOverlayMode || !item.isWidget + ); + + // Order items according to user preferences + const orderedItems = preferences.itemOrder + .map(id => filteredItems.find(item => item.id === id)) + .filter(Boolean) as NavItem[]; + + // Add any new items that aren't in the order yet (for backwards compatibility) + const itemsNotInOrder = filteredItems.filter(item => + !preferences.itemOrder.includes(item.id) + ); + + const result = [...orderedItems, ...itemsNotInOrder]; + console.log('CondensedNavigation: Computed navItems:', result.map(item => item.id)); + return result; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [navItemsBase, preferences.enabledItems, preferences.itemOrder, forceUpdate, isOverlayMode, currentTime, todayChatsCount, totalSessions, recipesCount, totalTokens]); + + // Listen for navigation preferences updates + useEffect(() => { + const handlePreferencesUpdate = (event: Event) => { + const customEvent = event as CustomEvent; + console.log('CondensedNavigation: Navigation preferences updated:', customEvent.detail); + // Force re-render when preferences change by updating multiple states + setForceUpdate(prev => prev + 1); + setItemOrder(prev => [...prev]); + }; + + window.addEventListener('navigation-preferences-updated', handlePreferencesUpdate); + return () => { + window.removeEventListener('navigation-preferences-updated', handlePreferencesUpdate); + }; + }, []); + + // Track value changes for pulse animation + useEffect(() => { + const currentValues: Record = { + chat: `${todayChatsCount}`, + history: `${totalSessions}`, + recipes: `${recipesCount}`, + scheduler: `${scheduledTodayCount}`, + tokens: `${totalTokens}`, + }; + + Object.entries(currentValues).forEach(([key, value]) => { + if (prevValues[key] && prevValues[key] !== value) { + setPulsingItems(prev => new Set(prev).add(key)); + setTimeout(() => { + setPulsingItems(prev => { + const next = new Set(prev); + next.delete(key); + return next; + }); + }, 2000); + } + }); + + setPrevValues(currentValues); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [todayChatsCount, totalSessions, recipesCount, scheduledTodayCount, totalTokens]); + + // Drag and drop handlers + const handleDragStart = (e: React.DragEvent, itemId: string) => { + setDraggedItem(itemId); + e.dataTransfer.effectAllowed = 'move'; + }; + + const handleDragOver = (e: React.DragEvent, itemId: string) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (draggedItem && draggedItem !== itemId) { + setDragOverItem(itemId); + } + }; + + const handleDrop = (e: React.DragEvent, dropItemId: string) => { + e.preventDefault(); + if (!draggedItem || draggedItem === dropItemId) return; + + const newOrder = [...itemOrder]; + const draggedIndex = newOrder.indexOf(draggedItem); + const dropIndex = newOrder.indexOf(dropItemId); + + newOrder.splice(draggedIndex, 1); + newOrder.splice(dropIndex, 0, draggedItem); + + setItemOrder(newOrder); + setDraggedItem(null); + setDragOverItem(null); + }; + + const handleDragEnd = () => { + setDraggedItem(null); + setDragOverItem(null); + }; + + const isActive = (path: string) => { + return location.pathname === path; + }; + + // Determine layout based on position + const isVertical = position === 'left' || position === 'right'; + + // Position-aware container classes for overlay mode (matches TopNavigation) + const containerClasses = isOverlayMode + ? `w-full h-full flex ${ + position === 'top' ? 'items-start justify-center' : + position === 'bottom' ? 'items-end justify-center' : + position === 'left' ? 'items-center justify-start' : + 'items-center justify-end' + }` + : isVertical + ? 'h-full' + : 'w-full overflow-hidden'; + + return ( +
+ + {isExpanded && ( + + +{isOverlayMode ? ( + // Overlay Mode: Independent menu container and widget tiles +
+ {/* Menu Container: Windows Start Menu style */} +
+ {/* Navigation Rows */} +
+ {navItems.filter(item => !item.isWidget).map((item, index) => { + const isPulsing = pulsingItems.has(item.id); + const isDragging = draggedItem === item.id; + const isDragOver = dragOverItem === item.id; + const IconComponent = item.icon; + const active = item.path ? isActive(item.path) : false; + + return ( + handleDragStart(e as unknown as React.DragEvent, item.id)} + onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent, item.id)} + onDrop={(e) => handleDrop(e as unknown as React.DragEvent, item.id)} + onDragEnd={handleDragEnd} + initial={{ opacity: 0, x: -20, scale: 0.9 }} + animate={{ + opacity: 1, + x: 0, + scale: isDragging ? 0.95 : 1, + }} + transition={{ + type: "spring", + stiffness: 350, + damping: 25, + delay: index * 0.02, + }} + className={` + relative cursor-move group w-full + ${isDragOver ? 'ring-2 ring-blue-500 rounded-lg' : ''} + `} + style={{ + opacity: isDragging ? 0.5 : 1, + }} + > + { + if (item.path) { + navigate(item.path); + setIsExpanded(false); + } + }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + className={` + flex flex-row items-center gap-2 w-full + relative rounded-lg transition-colors duration-200 no-drag + pl-2 pr-4 py-2.5 + ${active + ? 'bg-background-accent text-text-on-accent backdrop-blur-md' + : 'bg-background-default text-text-default hover:bg-background-medium backdrop-blur-md' + } + `} + > + {/* Drag handle indicator */} +
+ +
+ + {/* Icon */} + {IconComponent && } + + {/* Label */} + {item.label} + + {/* Tag/Badge */} + {item.getTag && ( +
+ + {item.getTag()} + +
+ )} + + {/* Update indicator dot */} + {isPulsing && ( + + )} +
+
+ ); + })} +
+
+ + {/* Widget Tiles: 2x2 Grid - Height matches menu container */} +
+ {(() => { + const widgets = navItems.filter(item => item.isWidget); + // For bottom position, reorder to L-shape: [0,1,2] -> [0,undefined,1,2] + // This creates: Top row: Clock (0), undefined | Bottom row: Activity (1), Tokens (2) + if (position === 'bottom' && widgets.length >= 3) { + return [widgets[0], undefined, widgets[1], widgets[2]]; + } + return widgets; + })().map((item, index) => { + if (!item) { + // Render empty placeholder for undefined slots + return ( +
+ ); + } + const isDragging = draggedItem === item.id; + const isDragOver = dragOverItem === item.id; + + return ( + handleDragStart(e as unknown as React.DragEvent, item.id)} + onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent, item.id)} + onDrop={(e) => handleDrop(e as unknown as React.DragEvent, item.id)} + onDragEnd={handleDragEnd} + initial={{ opacity: 0, x: 20, scale: 0.9 }} + animate={{ + opacity: 1, + x: 0, + scale: isDragging ? 0.95 : 1, + }} + transition={{ + type: "spring", + stiffness: 350, + damping: 25, + delay: (index + 9) * 0.02, // Delay after navigation items + }} + className={` + relative cursor-move group + ${isDragOver ? 'ring-2 ring-blue-500 rounded-2xl' : ''} + `} + style={{ + opacity: isDragging ? 0.5 : 1, + width: 'calc((500px - 2px) / 2)', // Match height for square tiles + height: 'calc((500px - 2px) / 2)', // (maxHeight - gap) / 2 rows + }} + > +
+ {/* Drag handle indicator */} +
+ +
+ {item.renderContent && item.renderContent()} +
+
+ ); + })} +
+
+ ) : ( + // Push Mode: Original single-column layout +
+ {/* Top spacer for vertical layout */} + {(isVertical || position === 'top') && ( +
+ )} + + {navItems.filter(item => !item.isWidget).map((item, index) => { + const isPulsing = pulsingItems.has(item.id); + const isDragging = draggedItem === item.id; + const isDragOver = dragOverItem === item.id; + const IconComponent = item.icon; + const active = item.path ? isActive(item.path) : false; + + return ( + handleDragStart(e as unknown as React.DragEvent, item.id)} + onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent, item.id)} + onDrop={(e) => handleDrop(e as unknown as React.DragEvent, item.id)} + onDragEnd={handleDragEnd} + initial={{ opacity: 0, [isVertical ? 'x' : 'y']: 20, scale: 0.9 }} + animate={{ + opacity: 1, + [isVertical ? 'x' : 'y']: 0, + scale: isDragging ? 0.95 : 1, + }} + transition={{ + type: "spring", + stiffness: 350, + damping: 25, + delay: index * 0.02, + }} + className={` + relative cursor-move group + ${isVertical ? 'w-full' : 'flex-1'} + ${isDragOver ? 'ring-2 ring-blue-500 rounded-lg' : ''} + `} + style={{ + opacity: isDragging ? 0.5 : 1, + }} + > + { + if (item.path) { + navigate(item.path); + setIsExpanded(false); + } + }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + className={` + flex flex-row items-center gap-2 w-full + relative rounded-lg transition-colors duration-200 no-drag + ${!isVertical ? 'px-2 pt-[18px] pb-[14px] min-[1800px]:pl-2 min-[1800px]:pr-4' : 'pl-2 pr-4 py-2.5'} + ${active + ? 'bg-background-accent text-text-on-accent' + : 'bg-background-default hover:bg-background-medium' + } + `} + > + {/* Drag handle indicator */} +
+ +
+ + {/* Icon */} + {IconComponent && } + + {/* Label */} + {item.label} + + {/* Tag/Badge */} + {item.getTag && ( +
+ + {item.getTag()} + +
+ )} + + {/* Update indicator dot */} + {isPulsing && ( + + )} +
+
+ ); + })} + + {/* Bottom spacer */} + {(isVertical || position === 'top' || position === 'bottom') && ( +
+ )} +
+ )} + + + )} + +
+ ); +}; diff --git a/ui/desktop/src/components/Layout/TopNavigation.tsx b/ui/desktop/src/components/Layout/TopNavigation.tsx new file mode 100644 index 0000000000..8fcc16cecd --- /dev/null +++ b/ui/desktop/src/components/Layout/TopNavigation.tsx @@ -0,0 +1,688 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { Home, History, FileText, Clock, Puzzle, Settings as SettingsIcon, GripVertical } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { ChatSmart } from '../icons'; +import { listSessions, getSessionInsights } from '../../api'; +import { useConfig } from '../ConfigContext'; +import { listSavedRecipes } from '../../recipe/recipe_management'; +import { useNavigationCustomization } from '../settings/app/NavigationCustomizationSettings'; + +interface NavItem { + id: string; + path?: string; + label: string; + icon?: React.ComponentType<{ className?: string }>; + getTag?: () => string; + tagAlign?: 'left' | 'right'; + isWidget?: boolean; + renderContent?: () => React.ReactNode; +} + +export type NavigationPosition = 'top' | 'bottom' | 'left' | 'right'; + +interface TopNavigationProps { + isExpanded: boolean; + setIsExpanded: (expanded: boolean) => void; + position?: NavigationPosition; + isOverlayMode?: boolean; +} + +// Analog Clock Widget Component +const AnalogClock: React.FC = () => { + const [secondAngle, setSecondAngle] = useState(() => { + const now = new Date(); + return now.getSeconds() * 6; + }); + const [angles, setAngles] = useState(() => { + const now = new Date(); + const hours = now.getHours() % 12; + const minutes = now.getMinutes(); + return { + hour: (hours * 30) + (minutes * 0.5), + minute: minutes * 6, + }; + }); + + useEffect(() => { + const interval = setInterval(() => { + const now = new Date(); + const hours = now.getHours() % 12; + const minutes = now.getMinutes(); + + setSecondAngle(prev => prev + 6); + + setAngles({ + hour: (hours * 30) + (minutes * 0.5), + minute: minutes * 6, + }); + }, 1000); + return () => clearInterval(interval); + }, []); + + const hourAngle = angles.hour; + const minuteAngle = angles.minute; + + return ( +
+
+ {/* Hour markers (ticks) - hidden on small/medium viewports, visible on large+ */} + {[...Array(12)].map((_, i) => ( +
+ ))} + + {/* Hour hand - always visible */} +
+ + {/* Minute hand - always visible */} +
+ + {/* Second hand - always visible */} +
+ + {/* Center dot - always visible */} +
+
+
+ ); +}; + +export const TopNavigation: React.FC = ({ isExpanded, setIsExpanded, position = 'top', isOverlayMode = false }) => { + const navigate = useNavigate(); + const location = useLocation(); + const { extensionsList, getExtensions } = useConfig(); + const { preferences } = useNavigationCustomization(); + const [forceUpdate, setForceUpdate] = useState(0); + const [currentTime, setCurrentTime] = useState(''); + const [todayChatsCount, setTodayChatsCount] = useState(0); + const [totalSessions, setTotalSessions] = useState(0); + const [recipesCount, setRecipesCount] = useState(0); + const [totalTokens, setTotalTokens] = useState(0); + + // Handle escape key to close overlay + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && isExpanded && isOverlayMode) { + event.preventDefault(); + event.stopPropagation(); + setIsExpanded(false); + } + }; + + if (isOverlayMode && isExpanded) { + document.addEventListener('keydown', handleKeyDown, { capture: true }); + return () => document.removeEventListener('keydown', handleKeyDown, { capture: true }); + } + return undefined; + }, [isExpanded, isOverlayMode, setIsExpanded]); + + const [sessionHeatmapData, setSessionHeatmapData] = useState>({}); + + const [prevValues, setPrevValues] = useState>({}); + const [pulsingItems, setPulsingItems] = useState>(new Set()); + + const [draggedItem, setDraggedItem] = useState(null); + const [dragOverItem, setDragOverItem] = useState(null); + const [tileOrder, setTileOrder] = useState([]); + + const [isUltraWide, setIsUltraWide] = useState(false); + + useEffect(() => { + const updateTime = () => { + const now = new Date(); + setCurrentTime(now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })); + }; + updateTime(); + const interval = setInterval(updateTime, 1000); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + const checkUltraWide = () => { + setIsUltraWide(window.innerWidth >= 2536); + }; + + checkUltraWide(); + window.addEventListener('resize', checkUltraWide); + + return () => window.removeEventListener('resize', checkUltraWide); + }, []); + + useEffect(() => { + if (isExpanded) { + fetchNavigationData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isExpanded]); + + const fetchNavigationData = async () => { + try { + const sessionsResponse = await listSessions({ throwOnError: false }); + if (sessionsResponse.data) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const heatmap: Record = {}; + let todayCount = 0; + + sessionsResponse.data.sessions.forEach((session) => { + const sessionDate = new Date(session.created_at); + const dateKey = sessionDate.toISOString().split('T')[0]; + heatmap[dateKey] = (heatmap[dateKey] || 0) + 1; + + const sessionDateOnly = new Date(session.created_at); + sessionDateOnly.setHours(0, 0, 0, 0); + if (sessionDateOnly.getTime() === today.getTime()) { + todayCount++; + } + }); + + setSessionHeatmapData(heatmap); + setTodayChatsCount(todayCount); + } + + const insightsResponse = await getSessionInsights({ throwOnError: false }); + if (insightsResponse.data) { + setTotalSessions(insightsResponse.data.totalSessions || 0); + setTotalTokens(insightsResponse.data.totalTokens || 0); + } + + try { + const recipes = await listSavedRecipes(); + setRecipesCount(recipes.length); + } catch (error) { + console.error('Failed to fetch recipes:', error); + setRecipesCount(0); + } + + try { + await getExtensions(true); + } catch (error) { + console.error('Failed to fetch extensions:', error); + } + } catch (error) { + console.error('Failed to fetch navigation data:', error); + } + }; + + const navItemsBase: NavItem[] = [ + { + id: 'home', + path: '/', + label: 'Home', + icon: Home, + getTag: () => currentTime, + }, + { + id: 'chat', + path: '/pair', + label: 'Chat', + icon: ChatSmart, + getTag: () => `${todayChatsCount} today`, + tagAlign: 'left', + }, + { + id: 'history', + path: '/sessions', + label: 'History', + icon: History, + getTag: () => `${totalSessions} total`, + tagAlign: 'left', + }, + { + id: 'recipes', + path: '/recipes', + label: 'Recipes', + icon: FileText, + getTag: () => `${recipesCount}`, + }, + { + id: 'scheduler', + path: '/schedules', + label: 'Scheduler', + icon: Clock, + }, + { + id: 'extensions', + path: '/extensions', + label: 'Extensions', + icon: Puzzle, + getTag: () => { + if (!extensionsList || !Array.isArray(extensionsList)) { + return '0 of 0 enabled'; + } + const enabled = extensionsList.filter(ext => ext.enabled).length; + const total = extensionsList.length; + return `${enabled} of ${total} enabled`; + }, + }, + { + id: 'settings', + path: '/settings', + label: 'Settings', + icon: SettingsIcon, + getTag: () => '✓', + tagAlign: 'left', + }, + { + id: 'placeholder-1', + path: '#', + label: '', + }, + { + id: 'placeholder-2', + path: '#', + label: '', + }, + { + id: 'clock-widget', + label: 'Clock', + isWidget: true, + renderContent: () => , + }, + { + id: 'activity-widget', + label: 'Activity', + isWidget: true, + renderContent: () => { + const days = 35; + const today = new Date(); + const heatmapCells = []; + + for (let i = days - 1; i >= 0; i--) { + const date = new Date(today); + date.setDate(date.getDate() - i); + const dateKey = date.toISOString().split('T')[0]; + const count = sessionHeatmapData[dateKey] || 0; + + const maxCount = Math.max(...Object.values(sessionHeatmapData), 1); + const intensity = count === 0 ? 0 : Math.ceil((count / maxCount) * 4); + + heatmapCells.push({ date: dateKey, count, intensity }); + } + + return ( +
+
+ {heatmapCells.map((cell, index) => ( +
+ ))} +
+
+ Last 35 days +
+
+ ); + }, + }, + { + id: 'tokens-widget', + label: 'Tokens', + isWidget: true, + renderContent: () => { + const tokensInMillions = (totalTokens / 1000000).toFixed(2); + + return ( +
+
+
+ {tokensInMillions}M +
+
+ Total tokens +
+
+
+ ); + }, + }, + ]; + + const navItems = React.useMemo(() => { + const enabledItems = navItemsBase.filter(item => + preferences.enabledItems.includes(item.id) + ); + + const orderedItems = preferences.itemOrder + .map(id => enabledItems.find(item => item.id === id)) + .filter(Boolean) as NavItem[]; + + const itemsNotInOrder = enabledItems.filter(item => + !preferences.itemOrder.includes(item.id) + ); + + return [...orderedItems, ...itemsNotInOrder]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [navItemsBase, preferences.enabledItems, preferences.itemOrder, forceUpdate, currentTime, todayChatsCount, totalSessions, recipesCount, totalTokens]); + + useEffect(() => { + const handlePreferencesUpdate = () => { + setForceUpdate(prev => prev + 1); + setTileOrder(prev => [...prev]); + }; + + window.addEventListener('navigation-preferences-updated', handlePreferencesUpdate); + return () => { + window.removeEventListener('navigation-preferences-updated', handlePreferencesUpdate); + }; + }, []); + + useEffect(() => { + const currentValues: Record = { + chat: `${todayChatsCount}`, + history: `${totalSessions}`, + recipes: `${recipesCount}`, + tokens: `${totalTokens}`, + }; + + Object.entries(currentValues).forEach(([key, value]) => { + if (prevValues[key] && prevValues[key] !== value) { + setPulsingItems(prev => new Set(prev).add(key)); + setTimeout(() => { + setPulsingItems(prev => { + const next = new Set(prev); + next.delete(key); + return next; + }); + }, 2000); + } + }); + + setPrevValues(currentValues); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [todayChatsCount, totalSessions, recipesCount, totalTokens]); + + const handleDragStart = (e: React.DragEvent, itemId: string) => { + setDraggedItem(itemId); + e.dataTransfer.effectAllowed = 'move'; + }; + + const handleDragOver = (e: React.DragEvent, itemId: string) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (draggedItem && draggedItem !== itemId) { + setDragOverItem(itemId); + } + }; + + const handleDrop = (e: React.DragEvent, dropItemId: string) => { + e.preventDefault(); + if (!draggedItem || draggedItem === dropItemId) return; + + const newOrder = [...tileOrder]; + const draggedIndex = newOrder.indexOf(draggedItem); + const dropIndex = newOrder.indexOf(dropItemId); + + newOrder.splice(draggedIndex, 1); + newOrder.splice(dropIndex, 0, draggedItem); + + setTileOrder(newOrder); + setDraggedItem(null); + setDragOverItem(null); + }; + + const handleDragEnd = () => { + setDraggedItem(null); + setDragOverItem(null); + }; + + const isActive = (path: string) => { + return location.pathname === path; + }; + + const isVertical = position === 'left' || position === 'right'; + + const gridClasses = isOverlayMode + ? 'grid gap-0.5 w-full max-w-7xl mx-auto px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8' + : isVertical + ? 'grid grid-cols-1 gap-0.5 h-full overflow-y-auto' + : 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 lg:grid-cols-6 xl:grid-cols-6 2xl:grid-cols-6 gap-0.5'; + + const gridStyle = isOverlayMode ? { + gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' + } : undefined; + + const containerClasses = isOverlayMode + ? 'w-full h-full flex items-center justify-center' // Always centered for overlay mode + : isVertical + ? 'h-full' + : 'w-full overflow-hidden'; + + return ( +
+ + {isExpanded && ( + + +
+ {navItems.map((item, index) => { + const isPulsing = pulsingItems.has(item.id); + const isDragging = draggedItem === item.id; + const isDragOver = dragOverItem === item.id; + + if (item.isWidget) { + return ( + handleDragStart(e as unknown as React.DragEvent, item.id)} + onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent, item.id)} + onDrop={(e) => handleDrop(e as unknown as React.DragEvent, item.id)} + onDragEnd={handleDragEnd} + initial={{ opacity: 0, y: 20, scale: 0.9 }} + animate={{ + opacity: 1, + y: 0, + scale: isDragging ? 0.95 : 1, + }} + transition={{ + type: "spring", + stiffness: 350, + damping: 25, + delay: index * 0.03, + }} + className={` + relative ${isOverlayMode ? 'bg-background-default backdrop-blur-md' : 'bg-background-default'} rounded-2xl + overflow-hidden cursor-move group + ${isDragOver ? 'ring-2 ring-blue-500' : ''} + ${isPulsing ? 'animate-pulse' : ''} + aspect-square + `} + style={{ + opacity: isDragging ? 0.5 : 1, + }} + > +
+ +
+ {item.renderContent && item.renderContent()} +
+ ); + } + + const IconComponent = item.icon!; + const active = isActive(item.path!); + + return ( + handleDragStart(e as unknown as React.DragEvent, item.id)} + onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent, item.id)} + onDrop={(e) => handleDrop(e as unknown as React.DragEvent, item.id)} + onDragEnd={handleDragEnd} + initial={{ opacity: 0, y: 20, scale: 0.9 }} + animate={{ + opacity: 1, + y: 0, + scale: isDragging ? 0.95 : 1, + }} + transition={{ + type: "spring", + stiffness: 350, + damping: 25, + delay: index * 0.03, + }} + className={` + relative cursor-move group + ${isDragOver ? 'ring-2 ring-blue-500 rounded-2xl' : ''} + `} + style={{ + opacity: isDragging ? 0.5 : 1, + }} + > + { + navigate(item.path!); + setIsExpanded(false); + }} + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + className={` + w-full relative flex flex-col items-start justify-between + rounded-2xl + ${isOverlayMode ? 'px-5 py-5' : 'px-6 py-6'} + transition-colors duration-200 + no-drag + ${isOverlayMode + ? (active + ? 'bg-background-accent text-text-on-accent backdrop-blur-md' + : 'bg-background-default text-text-default hover:bg-background-medium backdrop-blur-md' + ) + : (active + ? 'bg-background-accent text-text-on-accent' + : 'bg-background-default hover:bg-background-medium' + ) + } + aspect-square + `} + > +
+ +
+ + {isPulsing && ( + + )} + + {item.getTag && ( +
+ {item.getTag()} +
+ )} + +
+ {IconComponent && } + {item.label &&

{item.label}

} +
+
+
+ ); + })} +
+
+
+ )} +
+
+ ); +}; diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx index e349e285ff..20292504ed 100644 --- a/ui/desktop/src/components/settings/SettingsView.tsx +++ b/ui/desktop/src/components/settings/SettingsView.tsx @@ -29,11 +29,21 @@ export default function SettingsView({ setView: (view: View, viewOptions?: ViewOptions) => void; viewOptions: SettingsViewOptions; }) { - const [activeTab, setActiveTab] = useState('models'); + const [activeTab, setActiveTab] = useState(() => { + // Persist active tab in sessionStorage to survive re-renders + if (typeof window !== 'undefined' && window.sessionStorage) { + const stored = window.sessionStorage.getItem('settings_active_tab'); + return stored || 'models'; + } + return 'models'; + }); const hasTrackedInitialTab = useRef(false); const handleTabChange = (tab: string) => { setActiveTab(tab); + if (typeof window !== 'undefined' && window.sessionStorage) { + window.sessionStorage.setItem('settings_active_tab', tab); + } trackSettingsTabViewed(tab); }; diff --git a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx index 27aaa94171..8b4fce5319 100644 --- a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -1,14 +1,19 @@ import { useState, useEffect, useRef } from 'react'; import { Switch } from '../../ui/switch'; import { Button } from '../../ui/button'; -import { Settings } from 'lucide-react'; +import { Settings, ChevronDown } from 'lucide-react'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog'; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../ui/collapsible'; import UpdateSection from './UpdateSection'; import TunnelSection from '../tunnel/TunnelSection'; import { COST_TRACKING_ENABLED, UPDATES_ENABLED } from '../../../updates'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; import ThemeSelector from '../../GooseSidebar/ThemeSelector'; +import { NavigationPositionSelector } from './NavigationPositionSelector'; +import { NavigationStyleSelector } from './NavigationStyleSelector'; +import { NavigationModeSelector } from './NavigationModeSelector'; +import { NavigationCustomizationSettings } from './NavigationCustomizationSettings'; import BlockLogoBlack from './icons/block-lockup_black.png'; import BlockLogoWhite from './icons/block-lockup_white.png'; import TelemetrySettings from './TelemetrySettings'; @@ -27,6 +32,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti const [showNotificationModal, setShowNotificationModal] = useState(false); const [showPricing, setShowPricing] = useState(true); const [isDarkMode, setIsDarkMode] = useState(false); + const [navigationMode, setNavigationMode] = useState<'push' | 'overlay'>('push'); const updateSectionRef = useRef(null); // Check if GOOSE_VERSION is set to determine if Updates section should be shown @@ -62,6 +68,22 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti setShowPricing(stored !== 'false'); }, []); + // Load navigation mode and listen for changes + useEffect(() => { + const stored = localStorage.getItem('navigation_mode'); + setNavigationMode((stored as 'push' | 'overlay') || 'push'); + + const handleModeChange = (event: Event) => { + const customEvent = event as CustomEvent<{ mode: 'push' | 'overlay' }>; + setNavigationMode(customEvent.detail.mode); + }; + + window.addEventListener('navigation-mode-changed', handleModeChange); + return () => { + window.removeEventListener('navigation-mode-changed', handleModeChange); + }; + }, []); + // Handle scrolling to update section useEffect(() => { if (scrollToSection === 'update' && updateSectionRef.current) { @@ -269,6 +291,69 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti + + + + +
+
+ Navigation + Customize navigation appearance, position, and items +
+ +
+
+
+ + + {/* Navigation Mode */} +
+
+

Mode

+

+ {navigationMode === 'overlay' + ? 'Overlay mode: Centered expanded tiles launcher' + : 'Choose between push menu or overlay launcher'} +

+
+ +
+ + {/* Navigation Position - Only show for push mode */} + {navigationMode === 'push' && ( +
+
+

Position

+

Choose where the navigation bar appears

+
+ +
+ )} + + {/* Navigation Style - Only show for push mode */} + {navigationMode === 'push' && ( +
+
+

Style

+

Choose between expanded tiles or condensed rows

+
+ +
+ )} + + {/* Navigation Items */} +
+
+

Items

+

Customize which items appear and their order

+
+ +
+
+
+
+
+ diff --git a/ui/desktop/src/components/settings/app/NavigationCustomizationSettings.tsx b/ui/desktop/src/components/settings/app/NavigationCustomizationSettings.tsx new file mode 100644 index 0000000000..e72a0c7ad1 --- /dev/null +++ b/ui/desktop/src/components/settings/app/NavigationCustomizationSettings.tsx @@ -0,0 +1,193 @@ +import React, { useState, useCallback } from 'react'; +import { GripVertical, Eye, EyeOff } from 'lucide-react'; + +export interface NavigationPreferences { + itemOrder: string[]; + enabledItems: string[]; +} + +const DEFAULT_ITEM_ORDER = [ + 'home', + 'chat', + 'history', + 'recipes', + 'scheduler', + 'extensions', + 'settings', + 'placeholder-1', + 'placeholder-2', + 'clock-widget', + 'activity-widget', + 'tokens-widget', +]; + +const DEFAULT_ENABLED_ITEMS = [...DEFAULT_ITEM_ORDER]; + +const ITEM_LABELS: Record = { + home: 'Home', + chat: 'Chat', + history: 'History', + recipes: 'Recipes', + scheduler: 'Scheduler', + extensions: 'Extensions', + settings: 'Settings', + 'placeholder-1': 'Coming Soon 1', + 'placeholder-2': 'Coming Soon 2', + 'clock-widget': 'Clock Widget', + 'activity-widget': 'Activity Widget', + 'tokens-widget': 'Tokens Widget', +}; + +export const useNavigationCustomization = () => { + const [preferences, setPreferences] = useState(() => { + const stored = localStorage.getItem('navigation_preferences'); + if (stored) { + try { + return JSON.parse(stored); + } catch (e) { + console.error('Failed to parse navigation preferences:', e); + } + } + return { + itemOrder: DEFAULT_ITEM_ORDER, + enabledItems: DEFAULT_ENABLED_ITEMS, + }; + }); + + const updatePreferences = useCallback((newPreferences: NavigationPreferences) => { + setPreferences(newPreferences); + localStorage.setItem('navigation_preferences', JSON.stringify(newPreferences)); + window.dispatchEvent( + new CustomEvent('navigation-preferences-updated', { + detail: newPreferences, + }) + ); + }, []); + + return { preferences, updatePreferences }; +}; + +interface NavigationCustomizationSettingsProps { + className?: string; +} + +export const NavigationCustomizationSettings: React.FC = ({ + className, +}) => { + const { preferences, updatePreferences } = useNavigationCustomization(); + const [draggedItem, setDraggedItem] = useState(null); + const [dragOverItem, setDragOverItem] = useState(null); + + const handleDragStart = (e: React.DragEvent, itemId: string) => { + setDraggedItem(itemId); + e.dataTransfer.effectAllowed = 'move'; + }; + + const handleDragOver = (e: React.DragEvent, itemId: string) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (draggedItem && draggedItem !== itemId) { + setDragOverItem(itemId); + } + }; + + const handleDrop = (e: React.DragEvent, dropItemId: string) => { + e.preventDefault(); + if (!draggedItem || draggedItem === dropItemId) return; + + const newOrder = [...preferences.itemOrder]; + const draggedIndex = newOrder.indexOf(draggedItem); + const dropIndex = newOrder.indexOf(dropItemId); + + newOrder.splice(draggedIndex, 1); + newOrder.splice(dropIndex, 0, draggedItem); + + updatePreferences({ + ...preferences, + itemOrder: newOrder, + }); + + setDraggedItem(null); + setDragOverItem(null); + }; + + const handleDragEnd = () => { + setDraggedItem(null); + setDragOverItem(null); + }; + + const toggleItemEnabled = (itemId: string) => { + const newEnabledItems = preferences.enabledItems.includes(itemId) + ? preferences.enabledItems.filter((id) => id !== itemId) + : [...preferences.enabledItems, itemId]; + + updatePreferences({ + ...preferences, + enabledItems: newEnabledItems, + }); + }; + + const resetToDefaults = () => { + updatePreferences({ + itemOrder: DEFAULT_ITEM_ORDER, + enabledItems: DEFAULT_ENABLED_ITEMS, + }); + }; + + return ( +
+
+
+

+ Drag to reorder, click the eye icon to show/hide items +

+ +
+ + {preferences.itemOrder.map((itemId) => { + const isEnabled = preferences.enabledItems.includes(itemId); + const isDragging = draggedItem === itemId; + const isDragOver = dragOverItem === itemId; + + return ( +
handleDragStart(e, itemId)} + onDragOver={(e) => handleDragOver(e, itemId)} + onDrop={(e) => handleDrop(e, itemId)} + onDragEnd={handleDragEnd} + className={` + flex items-center gap-3 p-3 rounded-lg border transition-all + ${isDragging ? 'opacity-50' : 'opacity-100'} + ${isDragOver ? 'border-border-strong bg-background-medium' : 'border-border-subtle bg-background-default'} + ${!isEnabled ? 'opacity-50' : ''} + `} + > + + + {ITEM_LABELS[itemId] || itemId} + + +
+ ); + })} +
+
+ ); +}; diff --git a/ui/desktop/src/components/settings/app/NavigationModeSelector.tsx b/ui/desktop/src/components/settings/app/NavigationModeSelector.tsx new file mode 100644 index 0000000000..3f2e104f85 --- /dev/null +++ b/ui/desktop/src/components/settings/app/NavigationModeSelector.tsx @@ -0,0 +1,96 @@ +import React, { useState, useEffect } from 'react'; +import { PanelLeft, Layers } from 'lucide-react'; + +export type NavigationMode = 'push' | 'overlay'; + +interface NavigationModeSelectorProps { + className?: string; +} + +export const NavigationModeSelector: React.FC = ({ className }) => { + const [selectedMode, setSelectedMode] = useState(() => { + const stored = localStorage.getItem('navigation_mode'); + return (stored as NavigationMode) || 'push'; + }); + + useEffect(() => { + localStorage.setItem('navigation_mode', selectedMode); + + // When overlay mode is selected, automatically set position to center and style to expanded + // Use requestAnimationFrame to batch updates and prevent layout thrashing + if (selectedMode === 'overlay') { + localStorage.setItem('navigation_position', 'top'); // Center position + localStorage.setItem('navigation_style', 'expanded'); + + // Batch all events together in a single frame to prevent multiple re-renders + requestAnimationFrame(() => { + window.dispatchEvent( + new CustomEvent('navigation-position-changed', { + detail: { position: 'top' }, + }) + ); + window.dispatchEvent( + new CustomEvent('navigation-style-changed', { + detail: { style: 'expanded' }, + }) + ); + window.dispatchEvent( + new CustomEvent('navigation-mode-changed', { + detail: { mode: selectedMode }, + }) + ); + }); + } else { + // For push mode, only dispatch mode change event + requestAnimationFrame(() => { + window.dispatchEvent( + new CustomEvent('navigation-mode-changed', { + detail: { mode: selectedMode }, + }) + ); + }); + } + }, [selectedMode]); + + const modes: { value: NavigationMode; label: string; icon: React.ReactNode; description: string }[] = [ + { + value: 'push', + label: 'Push', + icon: , + description: 'Navigation pushes content', + }, + { + value: 'overlay', + label: 'Overlay', + icon: , + description: 'Full-screen overlay', + }, + ]; + + return ( +
+
+ {modes.map((mode) => ( + + ))} +
+
+ ); +}; diff --git a/ui/desktop/src/components/settings/app/NavigationPositionSelector.tsx b/ui/desktop/src/components/settings/app/NavigationPositionSelector.tsx new file mode 100644 index 0000000000..75719fb5f1 --- /dev/null +++ b/ui/desktop/src/components/settings/app/NavigationPositionSelector.tsx @@ -0,0 +1,71 @@ +import React, { useState, useEffect } from 'react'; +import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from 'lucide-react'; + +export type NavigationPosition = 'top' | 'bottom' | 'left' | 'right'; + +interface NavigationPositionSelectorProps { + className?: string; +} + +export const NavigationPositionSelector: React.FC = ({ className }) => { + const [selectedPosition, setSelectedPosition] = useState(() => { + const stored = localStorage.getItem('navigation_position'); + return (stored as NavigationPosition) || 'top'; + }); + + useEffect(() => { + localStorage.setItem('navigation_position', selectedPosition); + window.dispatchEvent( + new CustomEvent('navigation-position-changed', { + detail: { position: selectedPosition }, + }) + ); + }, [selectedPosition]); + + const positions: { value: NavigationPosition; label: string; icon: React.ReactNode }[] = [ + { + value: 'top', + label: 'Top', + icon: , + }, + { + value: 'bottom', + label: 'Bottom', + icon: , + }, + { + value: 'left', + label: 'Left', + icon: , + }, + { + value: 'right', + label: 'Right', + icon: , + }, + ]; + + return ( +
+
+ {positions.map((position) => ( + + ))} +
+
+ ); +}; diff --git a/ui/desktop/src/components/settings/app/NavigationStyleSelector.tsx b/ui/desktop/src/components/settings/app/NavigationStyleSelector.tsx new file mode 100644 index 0000000000..0a85b52e30 --- /dev/null +++ b/ui/desktop/src/components/settings/app/NavigationStyleSelector.tsx @@ -0,0 +1,66 @@ +import React, { useState, useEffect } from 'react'; +import { LayoutGrid, List } from 'lucide-react'; + +export type NavigationStyle = 'expanded' | 'condensed'; + +interface NavigationStyleSelectorProps { + className?: string; +} + +export const NavigationStyleSelector: React.FC = ({ className }) => { + const [selectedStyle, setSelectedStyle] = useState(() => { + const stored = localStorage.getItem('navigation_style'); + return (stored as NavigationStyle) || 'expanded'; + }); + + useEffect(() => { + localStorage.setItem('navigation_style', selectedStyle); + window.dispatchEvent( + new CustomEvent('navigation-style-changed', { + detail: { style: selectedStyle }, + }) + ); + }, [selectedStyle]); + + const styles: { value: NavigationStyle; label: string; icon: React.ReactNode; description: string }[] = [ + { + value: 'expanded', + label: 'Expanded', + icon: , + description: 'Large tiles with widgets', + }, + { + value: 'condensed', + label: 'Condensed', + icon: , + description: 'Compact rows', + }, + ]; + + return ( +
+
+ {styles.map((style) => ( + + ))} +
+
+ ); +}; diff --git a/ui/desktop/src/styles/main.css b/ui/desktop/src/styles/main.css index 930288787d..7333e0459f 100644 --- a/ui/desktop/src/styles/main.css +++ b/ui/desktop/src/styles/main.css @@ -776,3 +776,120 @@ p > code.bg-inline-code { .katex-error { color: var(--text-danger); } + +/* Navigation Component Animations */ +@keyframes nav-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes nav-scale-in { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes nav-slide-in-vertical { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes nav-slide-in-horizontal { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes nav-tile-in { + from { + opacity: 0; + transform: translateY(20px) scale(0.9); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Navigation overlay animations */ +.nav-overlay-enter { + animation: nav-scale-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.nav-overlay-content { + animation: nav-fade-in 0.2s ease-out 0.1s forwards; + opacity: 0; +} + +/* Navigation tile stagger animation */ +.nav-tile { + animation: nav-tile-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards; + opacity: 0; +} + +/* Stagger delays for tiles */ +.nav-tile:nth-child(1) { animation-delay: 0ms; } +.nav-tile:nth-child(2) { animation-delay: 30ms; } +.nav-tile:nth-child(3) { animation-delay: 60ms; } +.nav-tile:nth-child(4) { animation-delay: 90ms; } +.nav-tile:nth-child(5) { animation-delay: 120ms; } +.nav-tile:nth-child(6) { animation-delay: 150ms; } +.nav-tile:nth-child(7) { animation-delay: 180ms; } +.nav-tile:nth-child(8) { animation-delay: 210ms; } +.nav-tile:nth-child(9) { animation-delay: 240ms; } +.nav-tile:nth-child(10) { animation-delay: 270ms; } +.nav-tile:nth-child(11) { animation-delay: 300ms; } +.nav-tile:nth-child(12) { animation-delay: 330ms; } + +/* Navigation push mode animations */ +.nav-push-enter { + animation: nav-slide-in-vertical 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +/* Hover and active states for navigation buttons */ +.nav-button { + transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), + background-color 0.2s ease; +} + +.nav-button:hover { + transform: scale(1.02); +} + +.nav-button:active { + transform: scale(0.98); +} + +/* Reduce motion for accessibility */ +@media (prefers-reduced-motion: reduce) { + .nav-overlay-enter, + .nav-overlay-content, + .nav-tile, + .nav-push-enter, + .nav-button { + animation: none !important; + opacity: 1 !important; + transform: none !important; + transition: none !important; + } +}