New navigation settings layout options and styling (#6645)

Co-authored-by: Zane Staggs <zane@squareup.com>
Co-authored-by: Zane <75694352+zanesq@users.noreply.github.com>
This commit is contained in:
Spence
2026-02-23 11:56:51 -05:00
committed by GitHub
parent c73bad9a9c
commit 5349dd6098
45 changed files with 2446 additions and 1585 deletions
+161 -89
View File
@@ -1,12 +1,14 @@
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 { Outlet, useLocation } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Menu } from 'lucide-react';
import { Button } from '../ui/button';
import { Sidebar, SidebarInset, SidebarProvider, SidebarTrigger, useSidebar } from '../ui/sidebar';
import ChatSessionsContainer from '../ChatSessionsContainer';
import { useChatContext } from '../../contexts/ChatContext';
import { NavigationProvider, useNavigationContext } from './NavigationContext';
import { Navigation } from './NavigationPanel';
import { NAV_DIMENSIONS, Z_INDEX } from './constants';
import { cn } from '../../utils';
import { UserInput } from '../../types/message';
interface AppLayoutContentProps {
@@ -17,111 +19,181 @@ interface AppLayoutContentProps {
}
const AppLayoutContent: React.FC<AppLayoutContentProps> = ({ activeSessions }) => {
const navigate = useNavigate();
const location = useLocation();
const safeIsMacOS = (window?.electron?.platform || 'darwin') === 'darwin';
const { isMobile, openMobile } = useSidebar();
const chatContext = useChatContext();
const isOnPairRoute = location.pathname === '/pair';
const {
isNavExpanded,
setIsNavExpanded,
effectiveNavigationMode,
effectiveNavigationStyle,
navigationPosition,
isHorizontalNav,
isCondensedIconOnly,
} = useNavigationContext();
if (!chatContext) {
throw new Error('AppLayoutContent must be used within ChatProvider');
}
const { setChat } = chatContext;
// Calculate padding based on sidebar state and macOS
// Hide the titlebar drag region when nav is at the top in push mode,
// since the nav occupies that space and the drag region blocks interactions
const isPushTopNav =
effectiveNavigationMode === 'push' && navigationPosition === 'top' && isNavExpanded;
React.useEffect(() => {
const dragRegion = document.querySelector('.titlebar-drag-region') as HTMLElement | null;
if (!dragRegion) return;
if (isPushTopNav) {
dragRegion.style.display = 'none';
} else {
dragRegion.style.display = '';
}
return () => {
dragRegion.style.display = '';
};
}, [isPushTopNav]);
// Calculate padding based on macOS traffic lights
const headerPadding = safeIsMacOS ? 'pl-21' : 'pl-4';
// const headerPadding = '';
// Hide buttons when mobile sheet is showing
const shouldHideButtons = isMobile && openMobile;
// Determine flex direction based on navigation position (for push mode)
const getLayoutClass = () => {
if (effectiveNavigationMode === 'overlay') {
return 'flex-row';
}
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;
switch (navigationPosition) {
case 'top':
return 'flex-col';
case 'bottom':
return 'flex-col-reverse';
case 'left':
return 'flex-row';
case 'right':
return 'flex-row-reverse';
default:
navigate('/');
return 'flex-row';
}
};
const handleSelectSession = async (sessionId: string) => {
// Navigate to chat with session data
navigate('/', { state: { sessionId } });
};
const handleNewWindow = () => {
window.electron.createChatWindow({
dir: window.appConfig.get('GOOSE_WORKING_DIR') as string | undefined,
});
};
return (
<div className="flex flex-1 w-full min-h-0 relative animate-fade-in">
{!shouldHideButtons && (
<div className={`${headerPadding} absolute top-3 z-100 flex items-center`}>
<SidebarTrigger
className={`no-drag hover:border-border-secondary hover:text-text-primary hover:!bg-background-tertiary hover:scale-105`}
/>
<Button
onClick={handleNewWindow}
className="no-drag hover:!bg-background-tertiary"
variant="ghost"
size="xs"
title="Start a new session in a new window"
>
{safeIsMacOS ? <AppWindowMac className="w-4 h-4" /> : <AppWindow className="w-4 h-4" />}
</Button>
</div>
)}
<Sidebar variant="inset" collapsible="offcanvas">
<AppSidebar
onSelectSession={handleSelectSession}
setView={setView}
currentPath={location.pathname}
/>
</Sidebar>
<SidebarInset>
// Main content area
const mainContent = (
<div className="flex-1 overflow-hidden min-h-0">
<div className="h-full w-full bg-background-primary rounded-lg overflow-hidden">
<Outlet />
{/* Always render ChatSessionsContainer to keep SSE connections alive.
When navigating away from /pair */}
When navigating away from /pair, hide it with CSS */}
<div className={isOnPairRoute ? 'contents' : 'hidden'}>
<ChatSessionsContainer setChat={setChat} activeSessions={activeSessions} />
</div>
</SidebarInset>
</div>
</div>
);
return (
<div
className={cn(
'flex flex-1 w-full h-full relative animate-fade-in bg-background-secondary',
getLayoutClass()
)}
>
{/* Header controls */}
<div
style={{ zIndex: Z_INDEX.HEADER }}
className={cn(
'absolute flex items-center gap-1',
effectiveNavigationStyle === 'condensed' &&
navigationPosition === 'bottom' &&
effectiveNavigationMode === 'push'
? 'bottom-4 right-6'
: cn(
headerPadding,
'top-[11px]',
navigationPosition === 'right' ? 'right-6 left-auto' : 'ml-1.5'
)
)}
>
{/* Navigation trigger */}
<Button
onClick={() => setIsNavExpanded(!isNavExpanded)}
className="no-drag hover:!bg-background-tertiary"
variant="ghost"
size="xs"
title={isNavExpanded ? 'Close navigation' : 'Open navigation'}
>
<Menu className="w-5 h-5" />
</Button>
</div>
{/* Main content with navigation */}
<div className={cn('flex flex-1 w-full h-full min-h-0 p-[2px]', getLayoutClass())}>
{/* Push mode navigation (inline) with animation */}
{effectiveNavigationMode === 'push' && (
<motion.div
key="push-nav"
initial={false}
animate={{
width: isHorizontalNav
? '100%'
: isNavExpanded
? effectiveNavigationStyle === 'expanded'
? '30%'
: isCondensedIconOnly
? NAV_DIMENSIONS.CONDENSED_ICON_ONLY_WIDTH
: NAV_DIMENSIONS.CONDENSED_WIDTH
: 0,
height: isHorizontalNav
? isNavExpanded
? effectiveNavigationStyle === 'expanded'
? NAV_DIMENSIONS.EXPANDED_HEIGHT
: NAV_DIMENSIONS.CONDENSED_HEIGHT
: 0
: '100%',
}}
transition={{
type: 'spring',
stiffness: 400,
damping: 40,
}}
style={{
maxWidth:
!isHorizontalNav && effectiveNavigationStyle === 'expanded' ? '400px' : undefined,
minWidth:
!isHorizontalNav && effectiveNavigationStyle === 'condensed' && isNavExpanded
? isCondensedIconOnly
? NAV_DIMENSIONS.CONDENSED_ICON_ONLY_WIDTH
: NAV_DIMENSIONS.CONDENSED_WIDTH
: undefined,
minHeight:
isHorizontalNav && isNavExpanded
? effectiveNavigationStyle === 'expanded'
? NAV_DIMENSIONS.EXPANDED_HEIGHT
: NAV_DIMENSIONS.CONDENSED_HEIGHT
: undefined,
height: !isHorizontalNav ? '100%' : undefined,
}}
className={cn(
'flex-shrink-0',
effectiveNavigationStyle === 'condensed' && !isHorizontalNav
? 'overflow-visible'
: 'overflow-hidden',
isHorizontalNav ? 'w-full' : 'h-full'
)}
>
<Navigation />
</motion.div>
)}
{/* Main content */}
{mainContent}
</div>
{/* Overlay mode navigation */}
{effectiveNavigationMode === 'overlay' && <Navigation />}
</div>
);
};
@@ -135,8 +207,8 @@ interface AppLayoutProps {
export const AppLayout: React.FC<AppLayoutProps> = ({ activeSessions }) => {
return (
<SidebarProvider>
<NavigationProvider>
<AppLayoutContent activeSessions={activeSessions} />
</SidebarProvider>
</NavigationProvider>
);
};
@@ -0,0 +1,352 @@
import React, { useState } from 'react';
import { GripVertical, ChevronDown, ChevronRight, Plus } from 'lucide-react';
import { motion } from 'framer-motion';
import { cn } from '../../utils';
import { DropdownMenu, DropdownMenuTrigger } from '../ui/dropdown-menu';
import { ChatSessionsDropdown, SessionsList } from './navigation';
import type { NavigationRendererProps } from './navigation/types';
export const CondensedRenderer: React.FC<NavigationRendererProps> = ({
isOverlayMode,
navigationPosition,
isCondensedIconOnly,
className,
visibleItems,
isActive,
recentSessions,
activeSessionId,
onNavClick,
onNewChat,
onSessionClick,
onFetchSessions,
getSessionStatus,
clearUnread,
isChatExpanded,
onToggleChatExpanded,
drag,
navFocusRef,
}) => {
const [chatPopoverOpen, setChatPopoverOpen] = useState(false);
const isVertical = navigationPosition === 'left' || navigationPosition === 'right';
const isTopPosition = navigationPosition === 'top';
const isBottomPosition = navigationPosition === 'bottom';
return (
<motion.div
ref={navFocusRef}
tabIndex={-1}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className={cn(
'bg-app outline-none',
isOverlayMode && 'rounded-xl backdrop-blur-md shadow-lg p-2',
isVertical ? 'flex flex-col gap-[2px] h-full' : 'flex flex-row items-stretch gap-[2px]',
!isOverlayMode && navigationPosition === 'left' && !isCondensedIconOnly && 'pr-[2px]',
!isOverlayMode && navigationPosition === 'right' && !isCondensedIconOnly && 'pl-[2px]',
!isOverlayMode && isTopPosition && 'pb-[2px] pt-0',
!isOverlayMode && isBottomPosition && 'pt-[2px] pb-0',
!isCondensedIconOnly && 'overflow-visible',
className
)}
>
{/* Top spacer (vertical only) */}
{isVertical && (
<div
className={cn(
'bg-background-primary rounded-lg flex-shrink-0',
isCondensedIconOnly ? 'h-[80px] w-[40px]' : 'h-[48px] w-full'
)}
/>
)}
{/* Left spacer (horizontal top position only) */}
{!isVertical && isTopPosition && (
<div className="bg-background-primary rounded-lg self-stretch w-[160px] flex-shrink-0" />
)}
{/* Navigation items */}
{isVertical ? (
<div className="flex-1 min-h-0 flex flex-col gap-[2px]">
{visibleItems.map((item, index) => {
const Icon = item.icon;
const active = isActive(item.path);
const isDragging = drag.draggedItem === item.id;
const isDragOver = drag.dragOverItem === item.id;
const isChatItem = item.id === 'chat';
return (
<motion.div
key={item.id}
draggable
onDragStart={(e) => drag.onDragStart(e as unknown as React.DragEvent, item.id)}
onDragOver={(e) => drag.onDragOver(e as unknown as React.DragEvent, item.id)}
onDrop={(e) => drag.onDrop(e as unknown as React.DragEvent, item.id)}
onDragEnd={drag.onDragEnd}
initial={{ opacity: 0 }}
animate={{ opacity: isDragging ? 0.5 : 1 }}
transition={{ duration: 0.15, delay: index * 0.02 }}
className={cn(
'relative cursor-move group',
isCondensedIconOnly ? 'flex-shrink-0' : 'w-full flex-shrink-0',
isDragOver && 'ring-2 ring-blue-500 rounded-lg',
isChatItem && !isCondensedIconOnly && 'overflow-visible'
)}
>
<div
className={cn(
'flex flex-col',
isCondensedIconOnly ? 'items-start' : 'w-full',
isChatItem && !isCondensedIconOnly && 'overflow-visible'
)}
>
{/* Chat item with dropdown in icon-only mode */}
{isChatItem && isCondensedIconOnly ? (
<DropdownMenu open={chatPopoverOpen} onOpenChange={setChatPopoverOpen}>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex items-center justify-center',
'rounded-lg transition-colors duration-200 no-drag',
'p-2.5',
active
? 'bg-background-inverse text-text-inverse'
: 'bg-background-primary hover:bg-background-tertiary'
)}
>
<Icon className="w-5 h-5" />
</button>
</DropdownMenuTrigger>
<ChatSessionsDropdown
sessions={recentSessions}
activeSessionId={activeSessionId}
side={navigationPosition === 'left' ? 'right' : 'left'}
getSessionStatus={getSessionStatus}
clearUnread={clearUnread}
onNewChat={onNewChat}
onSessionClick={onSessionClick}
onShowAll={() => onNavClick('/sessions')}
/>
</DropdownMenu>
) : (
<>
{isChatItem && !isCondensedIconOnly ? (
<div className="relative">
<motion.button
onClick={onToggleChatExpanded}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={cn(
'flex flex-row items-center gap-2 outline-none',
'relative rounded-lg transition-colors duration-200 no-drag',
'w-full pl-2 pr-4 py-2.5',
active
? 'bg-background-inverse text-text-inverse'
: 'bg-background-primary hover:bg-background-tertiary'
)}
>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<GripVertical className="w-4 h-4 text-text-secondary" />
</div>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium text-left flex-1">
{item.label}
</span>
<div className="flex-shrink-0">
{isChatExpanded ? (
<ChevronDown className="w-3 h-3 text-text-secondary" />
) : (
<ChevronRight className="w-3 h-3 text-text-secondary" />
)}
</div>
</motion.button>
{!isChatExpanded && (
<motion.button
onClick={(e) => {
e.stopPropagation();
onNewChat();
}}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
className={cn(
'absolute -right-9 top-1/2 -translate-y-1/2 p-1.5 rounded-md z-10',
'opacity-0 group-hover:opacity-100 transition-opacity',
'bg-background-tertiary hover:bg-background-inverse hover:text-text-inverse',
'flex items-center justify-center'
)}
title="New Chat"
>
<Plus className="w-4 h-4" />
</motion.button>
)}
</div>
) : (
<motion.button
onClick={() => onNavClick(item.path)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={cn(
'flex flex-row items-center gap-2',
'relative rounded-lg transition-colors duration-200 no-drag',
isCondensedIconOnly
? 'justify-center p-2.5'
: 'w-full pl-2 pr-4 py-2.5',
active
? 'bg-background-inverse text-text-inverse'
: 'bg-background-primary hover:bg-background-tertiary'
)}
>
{!isCondensedIconOnly && (
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<GripVertical className="w-4 h-4 text-text-secondary" />
</div>
)}
<Icon className="w-5 h-5 flex-shrink-0" />
{!isCondensedIconOnly && (
<span className="text-sm font-medium text-left flex-1">
{item.label}
</span>
)}
{!isCondensedIconOnly && item.getTag && (
<div className="flex items-center gap-1 flex-shrink-0">
<span
className={cn(
'text-xs font-mono px-2 py-0.5 rounded-full',
active
? 'bg-background-primary/20 text-text-inverse/80'
: 'bg-background-secondary text-text-secondary'
)}
>
{item.getTag()}
</span>
</div>
)}
</motion.button>
)}
</>
)}
{isChatItem && !isCondensedIconOnly && (
<SessionsList
sessions={recentSessions}
activeSessionId={activeSessionId}
isExpanded={isChatExpanded}
getSessionStatus={getSessionStatus}
clearUnread={clearUnread}
onSessionClick={onSessionClick}
onSessionRenamed={onFetchSessions}
onNewChat={onNewChat}
onShowAll={() => onNavClick('/sessions')}
/>
)}
</div>
</motion.div>
);
})}
<div
className={cn(
'bg-background-primary rounded-lg flex-1 min-h-[40px]',
isCondensedIconOnly ? 'w-[40px]' : 'w-full'
)}
/>
</div>
) : (
/* Horizontal navigation items */
visibleItems.map((item, index) => {
const Icon = item.icon;
const active = isActive(item.path);
const isDragging = drag.draggedItem === item.id;
const isDragOver = drag.dragOverItem === item.id;
const isChatItem = item.id === 'chat';
return (
<motion.div
key={item.id}
draggable
onDragStart={(e) => drag.onDragStart(e as unknown as React.DragEvent, item.id)}
onDragOver={(e) => drag.onDragOver(e as unknown as React.DragEvent, item.id)}
onDrop={(e) => drag.onDrop(e as unknown as React.DragEvent, item.id)}
onDragEnd={drag.onDragEnd}
initial={{ opacity: 0 }}
animate={{ opacity: isDragging ? 0.5 : 1 }}
transition={{ duration: 0.15, delay: index * 0.02 }}
className={cn(
'relative cursor-move group flex-shrink-0',
isDragOver && 'ring-2 ring-blue-500 rounded-lg',
isChatItem && !isCondensedIconOnly && 'overflow-visible'
)}
>
<div className="flex flex-col">
{isChatItem ? (
<DropdownMenu open={chatPopoverOpen} onOpenChange={setChatPopoverOpen}>
<DropdownMenuTrigger asChild>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={cn(
'flex flex-row items-center justify-center gap-2',
'relative rounded-lg transition-colors duration-200 no-drag',
'px-3 py-2.5',
active
? 'bg-background-inverse text-text-inverse'
: 'bg-background-primary hover:bg-background-tertiary'
)}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium text-left hidden min-[1200px]:block">
{item.label}
</span>
</motion.button>
</DropdownMenuTrigger>
<ChatSessionsDropdown
sessions={recentSessions}
activeSessionId={activeSessionId}
side={isTopPosition ? 'bottom' : 'top'}
getSessionStatus={getSessionStatus}
clearUnread={clearUnread}
onNewChat={onNewChat}
onSessionClick={onSessionClick}
onShowAll={() => onNavClick('/sessions')}
/>
</DropdownMenu>
) : (
<motion.button
onClick={() => onNavClick(item.path)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className={cn(
'flex flex-row items-center gap-2 px-3 py-2.5',
'relative rounded-lg transition-colors duration-200 no-drag',
active
? 'bg-background-inverse text-text-inverse'
: 'bg-background-primary hover:bg-background-tertiary'
)}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium text-left hidden min-[1200px]:block">
{item.label}
</span>
</motion.button>
)}
</div>
</motion.div>
);
})
)}
{/* Right spacer (horizontal only) */}
{!isVertical && (
<div
className="bg-background-primary rounded-lg self-stretch flex-1 min-w-[40px]"
style={
!isOverlayMode && isTopPosition
? ({ WebkitAppRegion: 'drag' } as React.CSSProperties)
: undefined
}
/>
)}
</motion.div>
);
};
@@ -0,0 +1,324 @@
import React, { useState, useEffect, useRef } from 'react';
import { GripVertical } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Z_INDEX } from './constants';
import { cn } from '../../utils';
import { DropdownMenu, DropdownMenuTrigger } from '../ui/dropdown-menu';
import { ChatSessionsDropdown } from './navigation';
import type { NavigationRendererProps } from './navigation/types';
export const ExpandedRenderer: React.FC<NavigationRendererProps> = ({
isNavExpanded,
isOverlayMode,
navigationPosition,
onClose,
className,
visibleItems,
isActive,
recentSessions,
activeSessionId,
onNavClick,
onNewChat,
onSessionClick,
getSessionStatus,
clearUnread,
drag,
navFocusRef,
}) => {
const [chatDropdownOpen, setChatDropdownOpen] = useState(false);
const [gridColumns, setGridColumns] = useState(2);
const [gridMeasured, setGridMeasured] = useState(false);
const [tilesReady, setTilesReady] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const prevIsNavExpandedRef = useRef(isNavExpanded);
const gridRef = useRef<HTMLDivElement>(null);
// Detect when nav is closing
useEffect(() => {
if (prevIsNavExpandedRef.current && !isNavExpanded) {
setIsClosing(true);
setTilesReady(false);
} else if (!prevIsNavExpandedRef.current && isNavExpanded) {
setIsClosing(false);
}
prevIsNavExpandedRef.current = isNavExpanded;
}, [isNavExpanded]);
// Delay tiles animation until panel opens
useEffect(() => {
if (!isNavExpanded) {
setTilesReady(false);
return;
}
const timeoutId = setTimeout(() => setTilesReady(true), 150);
return () => clearTimeout(timeoutId);
}, [isNavExpanded]);
// Track grid columns for spacer tiles
useEffect(() => {
if (!isNavExpanded) {
setGridMeasured(false);
return;
}
setGridMeasured(false);
let rafId: number;
const updateGridColumns = () => {
if (!gridRef.current) return;
const parent = gridRef.current.parentElement;
if (!parent) return;
const parentStyle = window.getComputedStyle(parent);
const availableWidth =
parent.clientWidth -
parseFloat(parentStyle.paddingLeft) -
parseFloat(parentStyle.paddingRight);
const minSize = navigationPosition === 'left' || navigationPosition === 'right' ? 140 : 160;
const gap = isOverlayMode ? 12 : 2;
const cols = Math.max(1, Math.floor((availableWidth + gap) / (minSize + gap)));
setGridColumns(cols);
setGridMeasured(true);
};
const timeoutId = setTimeout(() => {
rafId = requestAnimationFrame(updateGridColumns);
}, 100);
const resizeObserver = new ResizeObserver(() => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(updateGridColumns);
});
const parent = gridRef.current?.parentElement;
if (parent) resizeObserver.observe(parent);
return () => {
clearTimeout(timeoutId);
cancelAnimationFrame(rafId);
resizeObserver.disconnect();
};
}, [isNavExpanded, navigationPosition, isOverlayMode]);
const isPushTopNav = !isOverlayMode && navigationPosition === 'top';
const dragStyle = isPushTopNav ? ({ WebkitAppRegion: 'drag' } as React.CSSProperties) : undefined;
const showContent = !isClosing || isOverlayMode;
const navContent = (
<motion.div
ref={navFocusRef}
tabIndex={-1}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className={cn(
'bg-app h-full overflow-hidden outline-none',
isOverlayMode && 'backdrop-blur-md shadow-2xl rounded-lg p-4',
!isOverlayMode && navigationPosition === 'top' && 'pb-[2px]',
!isOverlayMode && navigationPosition === 'bottom' && 'pt-[2px]',
!isOverlayMode && navigationPosition === 'left' && 'pr-[2px]',
!isOverlayMode && navigationPosition === 'right' && 'pl-[2px]',
className
)}
>
{showContent ? (
<div
ref={gridRef}
className={cn(
'grid gap-[2px] overflow-y-auto overflow-x-hidden h-full',
isOverlayMode && 'gap-3'
)}
style={{
...(dragStyle || {}),
gridTemplateColumns: isOverlayMode
? 'repeat(auto-fit, minmax(120px, 1fr))'
: navigationPosition === 'left' || navigationPosition === 'right'
? 'repeat(auto-fit, minmax(140px, 1fr))'
: 'repeat(auto-fit, minmax(160px, 1fr))',
alignContent: 'start',
}}
>
{visibleItems.map((item, index) => {
const Icon = item.icon;
const active = isActive(item.path);
const isDragging = drag.draggedItem === item.id;
const isDragOver = drag.dragOverItem === item.id;
const isChatItem = item.id === 'chat';
if (isChatItem) {
return (
<DropdownMenu
key={item.id}
open={chatDropdownOpen}
onOpenChange={setChatDropdownOpen}
>
<motion.div
draggable
onDragStart={(e) => drag.onDragStart(e as unknown as React.DragEvent, item.id)}
onDragOver={(e) => drag.onDragOver(e as unknown as React.DragEvent, item.id)}
onDrop={(e) => drag.onDrop(e as unknown as React.DragEvent, item.id)}
onDragEnd={drag.onDragEnd}
initial={{ opacity: 0 }}
animate={{ opacity: tilesReady ? (isDragging ? 0.5 : 1) : 0 }}
transition={{ duration: 0.15, delay: tilesReady ? index * 0.03 : 0 }}
className={cn(
'relative cursor-move group',
isDragOver && 'ring-2 ring-blue-500 rounded-lg'
)}
>
<div className="relative">
<DropdownMenuTrigger asChild>
<motion.div
className={cn(
'w-full relative flex flex-col rounded-lg',
'transition-colors duration-200 aspect-square cursor-pointer',
active
? 'bg-background-inverse text-text-inverse'
: 'bg-background-primary hover:bg-background-tertiary'
)}
>
<div className="flex-1 flex flex-col items-start justify-between p-5 no-drag text-left">
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<GripVertical className="w-4 h-4 text-text-secondary" />
</div>
{item.getTag && (
<div
className={cn(
'absolute top-3 px-2 py-1 rounded-full',
item.tagAlign === 'left' ? 'left-8' : 'right-8',
'bg-background-secondary'
)}
>
<span className="text-xs font-mono text-text-secondary">
{item.getTag()}
</span>
</div>
)}
<div className="mt-auto w-full">
<Icon className="w-6 h-6 mb-2" />
<h2 className="font-light text-left text-xl">{item.label}</h2>
</div>
</div>
</motion.div>
</DropdownMenuTrigger>
</div>
<ChatSessionsDropdown
sessions={recentSessions}
activeSessionId={activeSessionId}
side="right"
zIndex={Z_INDEX.DROPDOWN_ABOVE_OVERLAY}
getSessionStatus={getSessionStatus}
clearUnread={clearUnread}
onNewChat={onNewChat}
onSessionClick={onSessionClick}
onShowAll={() => onNavClick('/sessions')}
/>
</motion.div>
</DropdownMenu>
);
}
return (
<motion.div
key={item.id}
draggable
onDragStart={(e) => drag.onDragStart(e as unknown as React.DragEvent, item.id)}
onDragOver={(e) => drag.onDragOver(e as unknown as React.DragEvent, item.id)}
onDrop={(e) => drag.onDrop(e as unknown as React.DragEvent, item.id)}
onDragEnd={drag.onDragEnd}
initial={{ opacity: 0 }}
animate={{ opacity: tilesReady ? (isDragging ? 0.5 : 1) : 0 }}
transition={{ duration: 0.15, delay: tilesReady ? index * 0.03 : 0 }}
className={cn(
'relative cursor-move group',
isDragOver && 'ring-2 ring-blue-500 rounded-lg'
)}
>
<motion.div
className={cn(
'w-full relative flex flex-col rounded-lg',
'transition-colors duration-200 aspect-square',
active
? 'bg-background-inverse text-text-inverse'
: 'bg-background-primary hover:bg-background-tertiary'
)}
>
<button
onClick={() => onNavClick(item.path)}
className="flex-1 flex flex-col items-start justify-between p-5 no-drag text-left"
>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<GripVertical className="w-4 h-4 text-text-secondary" />
</div>
{item.getTag && (
<div
className={cn(
'absolute top-3 px-2 py-1 rounded-full',
item.tagAlign === 'left' ? 'left-8' : 'right-8',
'bg-background-secondary'
)}
>
<span className="text-xs font-mono text-text-secondary">
{item.getTag()}
</span>
</div>
)}
<div className="mt-auto w-full">
<Icon className="w-6 h-6 mb-2" />
<h2 className="font-light text-left text-xl">{item.label}</h2>
</div>
</button>
</motion.div>
</motion.div>
);
})}
{/* Spacer tiles */}
{!isOverlayMode &&
gridMeasured &&
gridColumns >= 2 &&
Array.from({
length:
navigationPosition === 'left' || navigationPosition === 'right'
? ((gridColumns - (visibleItems.length % gridColumns)) % gridColumns) +
gridColumns * 6
: (gridColumns - (visibleItems.length % gridColumns)) % gridColumns,
}).map((_, index) => (
<div key={`spacer-${index}`} className="relative">
<div className="w-full aspect-square rounded-lg bg-background-primary" />
</div>
))}
</div>
) : null}
</motion.div>
);
// Expanded overlay uses its own AnimatePresence
if (isOverlayMode) {
return (
<AnimatePresence>
{isNavExpanded && (
<div className="fixed inset-0" style={{ zIndex: Z_INDEX.OVERLAY }}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute inset-0 overflow-y-auto pointer-events-none">
<div className="min-h-full flex items-center justify-center p-8">
<div className="pointer-events-auto max-w-3xl w-full">{navContent}</div>
</div>
</div>
</div>
)}
</AnimatePresence>
);
}
return navContent;
};
@@ -0,0 +1,226 @@
import React, {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
export type NavigationMode = 'push' | 'overlay';
export type NavigationStyle = 'expanded' | 'condensed';
export type NavigationPosition = 'top' | 'bottom' | 'left' | 'right';
export interface NavigationPreferences {
itemOrder: string[];
enabledItems: string[];
}
export const DEFAULT_ITEM_ORDER = [
'home',
'chat',
'recipes',
'apps',
'scheduler',
'extensions',
'settings',
];
export const DEFAULT_ENABLED_ITEMS = [...DEFAULT_ITEM_ORDER];
const RESPONSIVE_BREAKPOINT = 700;
interface NavigationContextValue {
isNavExpanded: boolean;
setIsNavExpanded: (expanded: boolean) => void;
navigationMode: NavigationMode;
setNavigationMode: (mode: NavigationMode) => void;
effectiveNavigationMode: NavigationMode;
navigationStyle: NavigationStyle;
setNavigationStyle: (style: NavigationStyle) => void;
effectiveNavigationStyle: NavigationStyle;
navigationPosition: NavigationPosition;
setNavigationPosition: (position: NavigationPosition) => void;
preferences: NavigationPreferences;
updatePreferences: (prefs: NavigationPreferences) => void;
isHorizontalNav: boolean;
isCondensedIconOnly: boolean;
isOverlayMode: boolean;
isChatExpanded: boolean;
setIsChatExpanded: (expanded: boolean) => void;
}
const NavigationContext = createContext<NavigationContextValue | null>(null);
export const useNavigationContext = () => {
const context = useContext(NavigationContext);
if (!context) {
throw new Error('useNavigationContext must be used within NavigationProvider');
}
return context;
};
export const useNavigationContextSafe = () => {
return useContext(NavigationContext);
};
interface NavigationProviderProps {
children: ReactNode;
}
export const NavigationProvider: React.FC<NavigationProviderProps> = ({ children }) => {
const [isNavExpanded, setIsNavExpandedState] = useState<boolean>(() => {
const stored = localStorage.getItem('navigation_expanded');
return stored !== 'false';
});
const [isBelowBreakpoint, setIsBelowBreakpoint] = useState<boolean>(
() => window.innerWidth < RESPONSIVE_BREAKPOINT
);
const [navigationMode, setNavigationModeState] = useState<NavigationMode>(() => {
const stored = localStorage.getItem('navigation_mode');
return (stored as NavigationMode) || 'push';
});
const [navigationStyle, setNavigationStyleState] = useState<NavigationStyle>(() => {
const stored = localStorage.getItem('navigation_style');
return (stored as NavigationStyle) || 'condensed';
});
const [navigationPosition, setNavigationPositionState] = useState<NavigationPosition>(() => {
const stored = localStorage.getItem('navigation_position');
return (stored as NavigationPosition) || 'left';
});
const [preferences, setPreferences] = useState<NavigationPreferences>(() => {
const stored = localStorage.getItem('navigation_preferences');
if (stored) {
try {
return JSON.parse(stored);
} catch {
console.error('Failed to parse navigation preferences');
}
}
return {
itemOrder: DEFAULT_ITEM_ORDER,
enabledItems: DEFAULT_ENABLED_ITEMS,
};
});
const [isChatExpanded, setIsChatExpandedState] = useState<boolean>(() => {
const stored = localStorage.getItem('navigation_chat_expanded');
return stored !== 'false';
});
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${RESPONSIVE_BREAKPOINT - 1}px)`);
const onChange = () => setIsBelowBreakpoint(window.innerWidth < RESPONSIVE_BREAKPOINT);
mql.addEventListener('change', onChange);
setIsBelowBreakpoint(window.innerWidth < RESPONSIVE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);
const setIsNavExpanded = useCallback((expanded: boolean) => {
setIsNavExpandedState(expanded);
localStorage.setItem('navigation_expanded', String(expanded));
}, []);
const setNavigationMode = useCallback((mode: NavigationMode) => {
setNavigationModeState(mode);
localStorage.setItem('navigation_mode', mode);
window.dispatchEvent(new CustomEvent('navigation-mode-changed', { detail: { mode } }));
}, []);
const setNavigationStyle = useCallback((style: NavigationStyle) => {
setNavigationStyleState(style);
localStorage.setItem('navigation_style', style);
window.dispatchEvent(new CustomEvent('navigation-style-changed', { detail: { style } }));
}, []);
const setNavigationPosition = useCallback((position: NavigationPosition) => {
setNavigationPositionState(position);
localStorage.setItem('navigation_position', position);
window.dispatchEvent(new CustomEvent('navigation-position-changed', { detail: { position } }));
}, []);
const updatePreferences = useCallback((newPrefs: NavigationPreferences) => {
setPreferences(newPrefs);
localStorage.setItem('navigation_preferences', JSON.stringify(newPrefs));
window.dispatchEvent(new CustomEvent('navigation-preferences-updated', { detail: newPrefs }));
}, []);
const setIsChatExpanded = useCallback((expanded: boolean) => {
setIsChatExpandedState(expanded);
localStorage.setItem('navigation_chat_expanded', String(expanded));
}, []);
const isNavExpandedRef = useRef(isNavExpanded);
useEffect(() => {
isNavExpandedRef.current = isNavExpanded;
}, [isNavExpanded]);
useEffect(() => {
const handleToggleNavigation = () => {
setIsNavExpanded(!isNavExpandedRef.current);
};
window.electron.on('toggle-navigation', handleToggleNavigation);
return () => {
window.electron.off('toggle-navigation', handleToggleNavigation);
};
}, [setIsNavExpanded]);
useEffect(() => {
const handleModeChange = (e: Event) =>
setNavigationModeState((e as CustomEvent).detail.mode);
const handleStyleChange = (e: Event) =>
setNavigationStyleState((e as CustomEvent).detail.style);
const handlePositionChange = (e: Event) =>
setNavigationPositionState((e as CustomEvent).detail.position);
const handlePrefsChange = (e: Event) =>
setPreferences((e as CustomEvent).detail);
window.addEventListener('navigation-mode-changed', handleModeChange);
window.addEventListener('navigation-style-changed', handleStyleChange);
window.addEventListener('navigation-position-changed', handlePositionChange);
window.addEventListener('navigation-preferences-updated', handlePrefsChange);
return () => {
window.removeEventListener('navigation-mode-changed', handleModeChange);
window.removeEventListener('navigation-style-changed', handleStyleChange);
window.removeEventListener('navigation-position-changed', handlePositionChange);
window.removeEventListener('navigation-preferences-updated', handlePrefsChange);
};
}, []);
const isHorizontalNav = navigationPosition === 'top' || navigationPosition === 'bottom';
const effectiveNavigationMode: NavigationMode =
navigationStyle === 'expanded' && isBelowBreakpoint ? 'overlay' : navigationMode;
const effectiveNavigationStyle: NavigationStyle =
navigationMode === 'overlay' ? 'expanded' : navigationStyle;
const isCondensedIconOnly = !isHorizontalNav && isBelowBreakpoint;
const isOverlayMode = effectiveNavigationMode === 'overlay';
const value: NavigationContextValue = {
isNavExpanded,
setIsNavExpanded,
navigationMode,
setNavigationMode,
effectiveNavigationMode,
navigationStyle,
setNavigationStyle,
effectiveNavigationStyle,
navigationPosition,
setNavigationPosition,
preferences,
updatePreferences,
isHorizontalNav,
isCondensedIconOnly,
isOverlayMode,
isChatExpanded,
setIsChatExpanded,
};
return <NavigationContext.Provider value={value}>{children}</NavigationContext.Provider>;
};
@@ -0,0 +1,221 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useNavigationContext } from './NavigationContext';
import { useConfig } from '../ConfigContext';
import { useNavigationSessions } from '../../hooks/useNavigationSessions';
import { getNavItemById, type NavItem } from '../../hooks/useNavigationItems';
import { AppEvents } from '../../constants/events';
import { CondensedRenderer } from './CondensedRenderer';
import { ExpandedRenderer } from './ExpandedRenderer';
import { NavigationOverlay } from './navigation';
import type { SessionStatus, DragHandlers } from './navigation/types';
export const Navigation: React.FC<{ className?: string }> = ({ className }) => {
const {
isNavExpanded,
setIsNavExpanded,
navigationPosition,
preferences,
updatePreferences,
isCondensedIconOnly,
isOverlayMode,
effectiveNavigationStyle,
isChatExpanded,
setIsChatExpanded,
} = useNavigationContext();
const location = useLocation();
const { extensionsList } = useConfig();
const appsExtensionEnabled = !!extensionsList?.find((ext) => ext.name === 'apps')?.enabled;
const visibleItems = useMemo(() => {
return preferences.itemOrder
.filter((id) => preferences.enabledItems.includes(id))
.map((id) => getNavItemById(id))
.filter((item): item is NavItem => item !== undefined)
.filter((item) => {
if (item.path === '/apps') return appsExtensionEnabled;
return true;
});
}, [preferences.itemOrder, preferences.enabledItems, appsExtensionEnabled]);
const isActive = useCallback((path: string) => location.pathname === path, [location.pathname]);
const {
recentSessions,
activeSessionId,
fetchSessions,
handleNavClick,
handleNewChat,
handleSessionClick,
} = useNavigationSessions({
onNavigate: isOverlayMode ? () => setIsNavExpanded(false) : undefined,
});
const [draggedItem, setDraggedItem] = useState<string | null>(null);
const [dragOverItem, setDragOverItem] = useState<string | null>(null);
const onDragStart = useCallback((e: React.DragEvent, itemId: string) => {
setDraggedItem(itemId);
e.dataTransfer.effectAllowed = 'move';
}, []);
const onDragOver = useCallback(
(e: React.DragEvent, itemId: string) => {
e.preventDefault();
if (draggedItem && draggedItem !== itemId) setDragOverItem(itemId);
},
[draggedItem]
);
const onDrop = useCallback(
(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);
if (draggedIndex === -1 || dropIndex === -1) return;
newOrder.splice(draggedIndex, 1);
newOrder.splice(dropIndex, 0, draggedItem);
updatePreferences({ ...preferences, itemOrder: newOrder });
setDraggedItem(null);
setDragOverItem(null);
},
[draggedItem, preferences, updatePreferences]
);
const onDragEnd = useCallback(() => {
setDraggedItem(null);
setDragOverItem(null);
}, []);
const drag: DragHandlers = {
draggedItem,
dragOverItem,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
};
const [sessionStatuses, setSessionStatuses] = useState<Map<string, SessionStatus>>(new Map());
useEffect(() => {
const handleStatusUpdate = (event: Event) => {
const { sessionId, streamState } = (event as CustomEvent).detail;
setSessionStatuses((prev) => {
const existing = prev.get(sessionId);
const shouldMarkUnread = existing?.streamState === 'streaming' && streamState === 'idle';
const next = new Map(prev);
next.set(sessionId, {
streamState,
hasUnreadActivity: existing?.hasUnreadActivity || shouldMarkUnread,
});
return next;
});
};
window.addEventListener(AppEvents.SESSION_STATUS_UPDATE, handleStatusUpdate);
return () => window.removeEventListener(AppEvents.SESSION_STATUS_UPDATE, handleStatusUpdate);
}, []);
const getSessionStatus = useCallback(
(sessionId: string) => sessionStatuses.get(sessionId),
[sessionStatuses]
);
const clearUnread = useCallback((sessionId: string) => {
setSessionStatuses((prev) => {
const status = prev.get(sessionId);
if (status?.hasUnreadActivity) {
const next = new Map(prev);
next.set(sessionId, { ...status, hasUnreadActivity: false });
return next;
}
return prev;
});
}, []);
useEffect(() => {
if (!(isOverlayMode && isNavExpanded)) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
setIsNavExpanded(false);
}
};
document.addEventListener('keydown', handleKeyDown, { capture: true });
return () => document.removeEventListener('keydown', handleKeyDown, { capture: true });
}, [isNavExpanded, isOverlayMode, setIsNavExpanded]);
const navFocusRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isNavExpanded) {
fetchSessions();
requestAnimationFrame(() => navFocusRef.current?.focus());
}
}, [isNavExpanded, fetchSessions]);
const onToggleChatExpanded = useCallback(() => {
setIsChatExpanded(!isChatExpanded);
}, [isChatExpanded, setIsChatExpanded]);
const onClose = useCallback(() => setIsNavExpanded(false), [setIsNavExpanded]);
const rendererProps = {
isNavExpanded,
isOverlayMode,
navigationPosition,
isCondensedIconOnly,
onClose,
className,
visibleItems,
isActive,
recentSessions,
activeSessionId,
onNavClick: handleNavClick,
onNewChat: handleNewChat,
onSessionClick: handleSessionClick,
onFetchSessions: fetchSessions,
getSessionStatus,
clearUnread,
isChatExpanded,
onToggleChatExpanded,
drag,
navFocusRef,
};
const content =
effectiveNavigationStyle === 'expanded' ? (
<ExpandedRenderer {...rendererProps} />
) : (
<CondensedRenderer {...rendererProps} />
);
if (isOverlayMode) {
if (effectiveNavigationStyle === 'expanded') {
// Expanded overlay uses its own AnimatePresence layout
return content;
}
return (
<NavigationOverlay
isOpen={isNavExpanded}
position={navigationPosition}
onClose={() => setIsNavExpanded(false)}
>
{content}
</NavigationOverlay>
);
}
if (!isNavExpanded) return null;
return content;
};
@@ -0,0 +1,23 @@
export const NAV_DIMENSIONS = {
/** Width of condensed navigation in icon-only mode */
CONDENSED_ICON_ONLY_WIDTH: 44,
/** Width of condensed navigation with labels */
CONDENSED_WIDTH: 200,
/** Height of expanded navigation (horizontal mode) */
EXPANDED_HEIGHT: 180,
/** Height of condensed navigation (horizontal mode) */
CONDENSED_HEIGHT: 46,
} as const;
export const Z_INDEX = {
/** Header controls (menu button, etc.) */
HEADER: 100,
/** Tooltips - should appear above most UI elements */
TOOLTIP: 200,
/** Popover content (hover menus) */
POPOVER: 9999,
/** Modal/overlay backdrop and content */
OVERLAY: 10000,
/** Dropdown menus that appear above overlays */
DROPDOWN_ABOVE_OVERLAY: 10001,
} as const;
@@ -0,0 +1,105 @@
import React from 'react';
import { MessageSquare, History, Plus, ChefHat } from 'lucide-react';
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from '../../ui/dropdown-menu';
import { SessionIndicators } from '../../SessionIndicators';
import { cn } from '../../../utils';
import { getSessionDisplayName, truncateMessage } from '../../../hooks/useNavigationSessions';
import type { Session } from '../../../api';
import type { SessionStatus } from './types';
interface ChatSessionsDropdownProps {
sessions: Session[];
activeSessionId?: string;
side?: 'top' | 'bottom' | 'left' | 'right';
zIndex?: number;
getSessionStatus: (sessionId: string) => SessionStatus | undefined;
clearUnread: (sessionId: string) => void;
onNewChat: () => void;
onSessionClick: (sessionId: string) => void;
onShowAll: () => void;
}
export const ChatSessionsDropdown: React.FC<ChatSessionsDropdownProps> = ({
sessions,
activeSessionId,
side = 'right',
zIndex,
getSessionStatus,
clearUnread,
onNewChat,
onSessionClick,
onShowAll,
}) => {
return (
<DropdownMenuContent
className="w-64 p-1 bg-background-primary border-border-secondary rounded-lg shadow-lg"
side={side}
align="start"
sideOffset={8}
style={zIndex ? { zIndex } : undefined}
>
<DropdownMenuItem
onClick={onNewChat}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg cursor-pointer"
>
<Plus className="w-4 h-4 flex-shrink-0" />
<span>New Chat</span>
</DropdownMenuItem>
{sessions.length > 0 && <DropdownMenuSeparator className="my-1" />}
{sessions.map((session) => {
const status = getSessionStatus(session.id);
const isStreaming = status?.streamState === 'streaming';
const hasError = status?.streamState === 'error';
const hasUnread = status?.hasUnreadActivity ?? false;
const isActiveSession = session.id === activeSessionId;
return (
<DropdownMenuItem
key={session.id}
onClick={() => {
clearUnread(session.id);
onSessionClick(session.id);
}}
className={cn(
'flex items-center gap-2 px-3 py-2 text-sm rounded-lg cursor-pointer',
isActiveSession && 'bg-background-tertiary'
)}
>
{session.recipe ? (
<ChefHat className="w-4 h-4 flex-shrink-0 text-text-secondary" />
) : (
<MessageSquare className="w-4 h-4 flex-shrink-0 text-text-secondary" />
)}
<span className="truncate flex-1">
{truncateMessage(getSessionDisplayName(session), 30)}
</span>
<SessionIndicators
isStreaming={isStreaming}
hasUnread={hasUnread}
hasError={hasError}
/>
</DropdownMenuItem>
);
})}
{sessions.length > 0 && (
<>
<DropdownMenuSeparator className="my-1" />
<DropdownMenuItem
onClick={onShowAll}
className="flex items-center gap-2 px-3 py-2 text-sm rounded-lg cursor-pointer text-text-secondary"
>
<History className="w-4 h-4 flex-shrink-0" />
<span>Show All</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
);
};
@@ -0,0 +1,52 @@
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '../../../utils';
import { Z_INDEX } from '../constants';
type NavigationPosition = 'top' | 'bottom' | 'left' | 'right';
interface NavigationOverlayProps {
isOpen: boolean;
position: NavigationPosition;
onClose: () => void;
children: React.ReactNode;
}
export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
isOpen,
position,
onClose,
children,
}) => {
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0" style={{ zIndex: Z_INDEX.OVERLAY }}>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/20 backdrop-blur-sm"
onClick={onClose}
/>
{/* Scrollable container for navigation panel */}
<div className="absolute inset-0 overflow-y-auto pointer-events-none">
<div
className={cn(
'min-h-full flex p-4',
position === 'top' && 'items-start justify-center pt-16',
position === 'bottom' && 'items-end justify-center pb-8',
position === 'left' && 'items-center justify-start pl-4',
position === 'right' && 'items-center justify-end pr-4'
)}
>
<div className="pointer-events-auto">{children}</div>
</div>
</div>
</div>
)}
</AnimatePresence>
);
};
@@ -0,0 +1,145 @@
import React, { useState, useCallback } from 'react';
import { MessageSquare, ChefHat, Plus, History } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { SessionIndicators } from '../../SessionIndicators';
import { InlineEditText } from '../../common/InlineEditText';
import { cn } from '../../../utils';
import { getSessionDisplayName } from '../../../hooks/useNavigationSessions';
import { updateSessionName } from '../../../api';
import type { Session } from '../../../api';
import type { SessionStatus } from './types';
interface SessionsListProps {
sessions: Session[];
activeSessionId?: string;
isExpanded: boolean;
getSessionStatus: (sessionId: string) => SessionStatus | undefined;
clearUnread: (sessionId: string) => void;
onSessionClick: (sessionId: string) => void;
onSessionRenamed?: () => void;
onNewChat?: () => void;
onShowAll?: () => void;
}
export const SessionsList: React.FC<SessionsListProps> = ({
sessions,
activeSessionId,
isExpanded,
getSessionStatus,
clearUnread,
onSessionClick,
onSessionRenamed,
onNewChat,
onShowAll,
}) => {
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const handleSaveSessionName = useCallback(
async (sessionId: string, newName: string) => {
await updateSessionName({
path: { session_id: sessionId },
body: { name: newName },
});
onSessionRenamed?.();
},
[onSessionRenamed]
);
return (
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden mt-[2px]"
>
<div className="bg-background-primary rounded-lg py-1 flex flex-col gap-[2px]">
{/* New Chat button as first item */}
{onNewChat && (
<div
onClick={onNewChat}
className={cn(
'w-full text-left py-1.5 px-2 text-xs rounded-md',
'hover:bg-background-tertiary transition-colors',
'flex items-center gap-2 cursor-pointer'
)}
>
<div className="w-4 flex-shrink-0" />
<Plus className="w-4 h-4 flex-shrink-0 text-text-secondary" />
<span className="text-text-primary">Start New Chat</span>
</div>
)}
{sessions.map((session) => {
const status = getSessionStatus(session.id);
const isStreaming = status?.streamState === 'streaming';
const hasError = status?.streamState === 'error';
const hasUnread = status?.hasUnreadActivity ?? false;
const isActiveSession = session.id === activeSessionId;
const isEditing = editingSessionId === session.id;
return (
<div
key={session.id}
onClick={() => {
if (!isEditing) {
clearUnread(session.id);
onSessionClick(session.id);
}
}}
className={cn(
'w-full text-left py-1.5 px-2 text-xs rounded-md',
'hover:bg-background-tertiary transition-colors',
'flex items-center gap-2 cursor-pointer',
isActiveSession && 'bg-background-tertiary'
)}
>
<div className="w-4 flex-shrink-0" />
{session.recipe ? (
<ChefHat className="w-4 h-4 flex-shrink-0 text-text-secondary" />
) : (
<MessageSquare className="w-4 h-4 flex-shrink-0 text-text-secondary" />
)}
<InlineEditText
value={getSessionDisplayName(session)}
onSave={(newName) => handleSaveSessionName(session.id, newName)}
placeholder="Untitled session"
disabled={isStreaming}
singleClickEdit={false}
className="truncate text-text-primary flex-1 !px-0 !py-0 hover:bg-transparent"
editClassName="!text-xs"
onEditStart={() => setEditingSessionId(session.id)}
onEditEnd={() => setEditingSessionId(null)}
/>
<SessionIndicators
isStreaming={isStreaming}
hasUnread={hasUnread}
hasError={hasError}
/>
</div>
);
})}
{/* Show All button at bottom */}
{onShowAll && sessions.length > 0 && (
<div
onClick={onShowAll}
className={cn(
'w-full text-left py-1.5 px-2 text-xs rounded-md',
'hover:bg-background-tertiary transition-colors',
'flex items-center gap-2 cursor-pointer text-text-secondary'
)}
>
<div className="w-4 flex-shrink-0" />
<History className="w-4 h-4 flex-shrink-0" />
<span>Show All</span>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
);
};
@@ -0,0 +1,3 @@
export { ChatSessionsDropdown } from './ChatSessionsDropdown';
export { NavigationOverlay } from './NavigationOverlay';
export { SessionsList } from './SessionsList';
@@ -0,0 +1,54 @@
import type { NavItem } from '../../../hooks/useNavigationItems';
import type { Session } from '../../../api';
import type { NavigationPosition } from '../NavigationContext';
export type StreamState = 'idle' | 'loading' | 'streaming' | 'error';
export interface SessionStatus {
streamState: StreamState;
hasUnreadActivity: boolean;
}
export interface DragHandlers {
draggedItem: string | null;
dragOverItem: string | null;
onDragStart: (e: React.DragEvent, itemId: string) => void;
onDragOver: (e: React.DragEvent, itemId: string) => void;
onDrop: (e: React.DragEvent, dropItemId: string) => void;
onDragEnd: () => void;
}
export interface NavigationRendererProps {
isNavExpanded: boolean;
isOverlayMode: boolean;
navigationPosition: NavigationPosition;
isCondensedIconOnly: boolean;
onClose: () => void;
className?: string;
// Items
visibleItems: NavItem[];
isActive: (path: string) => boolean;
// Sessions
recentSessions: Session[];
activeSessionId?: string;
onNavClick: (path: string) => void;
onNewChat: () => void;
onSessionClick: (sessionId: string) => void;
onFetchSessions: () => void;
// Session status
getSessionStatus: (sessionId: string) => SessionStatus | undefined;
clearUnread: (sessionId: string) => void;
// Chat expand (condensed only, but simpler to keep uniform)
isChatExpanded: boolean;
onToggleChatExpanded: () => void;
// Drag and drop
drag: DragHandlers;
// Ref for focus management
navFocusRef: React.RefObject<HTMLDivElement | null>;
}