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:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user