mirror of https://github.com/kortix-ai/suna.git
507 lines
18 KiB
TypeScript
507 lines
18 KiB
TypeScript
import { useDeleteProject, useProjects } from '@/api/project-api';
|
|
import { fontWeights } from '@/constants/Fonts';
|
|
import { usePanelTopOffset } from '@/constants/SafeArea';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import { useThemedStyles } from '@/hooks/useThemeColor';
|
|
import { useIsNewChatMode, useResetNewChatSession, useSelectedProject, useSetNewChatMode, useSetSelectedProject } from '@/stores/ui-store';
|
|
import { ChevronsUpDown, SquarePen } from 'lucide-react-native';
|
|
import React, { useRef, useState } from 'react';
|
|
import { ScrollView, TouchableOpacity, View } from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { ChatActionModal } from './ChatActionModal';
|
|
import { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
|
import { SettingsDrawer } from './SettingsDrawer';
|
|
import { ShareModal } from './ShareModal';
|
|
import { SkeletonProjects } from './Skeleton';
|
|
import { Body, Caption, H3 } from './Typography';
|
|
|
|
interface LeftPanelProps {
|
|
isVisible: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export const LeftPanel: React.FC<LeftPanelProps> = ({ isVisible, onClose }) => {
|
|
const insets = useSafeAreaInsets();
|
|
const panelTopOffset = usePanelTopOffset();
|
|
|
|
// Modal states
|
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
|
const [shareModalVisible, setShareModalVisible] = useState(false);
|
|
const [actionModalVisible, setActionModalVisible] = useState(false);
|
|
const [settingsDrawerVisible, setSettingsDrawerVisible] = useState(false);
|
|
const [projectToDelete, setProjectToDelete] = useState<{ id: string; name: string } | null>(null);
|
|
const [projectToShare, setProjectToShare] = useState<{ id: string; name: string; isPublic?: boolean } | null>(null);
|
|
const [selectedChatLayout, setSelectedChatLayout] = useState<{ x: number; y: number; width: number; height: number } | undefined>();
|
|
|
|
// Refs for layout measurement
|
|
const chatItemRefs = useRef<{ [key: string]: View | null }>({});
|
|
|
|
// Use React Query to fetch projects
|
|
const { data: projects = [], isLoading, error } = useProjects();
|
|
const deleteProjectMutation = useDeleteProject();
|
|
|
|
// Use auth context
|
|
const { user } = useAuth();
|
|
|
|
// Use zustand for chat state
|
|
const selectedProject = useSelectedProject();
|
|
const setSelectedProject = useSetSelectedProject();
|
|
const isNewChatMode = useIsNewChatMode();
|
|
const setNewChatMode = useSetNewChatMode();
|
|
const resetNewChatSession = useResetNewChatSession();
|
|
|
|
const styles = useThemedStyles((theme) => ({
|
|
panel: {
|
|
backgroundColor: theme.sidebar,
|
|
width: 300,
|
|
height: '100%' as const,
|
|
paddingTop: panelTopOffset,
|
|
paddingBottom: insets.bottom,
|
|
},
|
|
header: {
|
|
flexDirection: 'row' as const,
|
|
justifyContent: 'space-between' as const,
|
|
alignItems: 'center' as const,
|
|
paddingHorizontal: 20,
|
|
paddingBottom: 0,
|
|
},
|
|
title: {
|
|
color: theme.foreground,
|
|
},
|
|
closeButton: {
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 16,
|
|
backgroundColor: theme.mutedWithOpacity(0.1),
|
|
justifyContent: 'center' as const,
|
|
alignItems: 'center' as const,
|
|
},
|
|
scrollContent: {
|
|
flex: 1,
|
|
paddingHorizontal: 22,
|
|
},
|
|
section: {
|
|
marginTop: 24,
|
|
},
|
|
sectionItem: {
|
|
flexDirection: 'row' as const,
|
|
alignItems: 'center' as const,
|
|
paddingVertical: 12,
|
|
paddingHorizontal: 16,
|
|
borderRadius: 8,
|
|
marginBottom: 8,
|
|
},
|
|
sectionItemWithBadge: {
|
|
flexDirection: 'row' as const,
|
|
alignItems: 'center' as const,
|
|
justifyContent: 'space-between' as const,
|
|
paddingVertical: 12,
|
|
borderRadius: 10,
|
|
marginBottom: 8,
|
|
},
|
|
sectionItemLeft: {
|
|
flexDirection: 'row' as const,
|
|
alignItems: 'center' as const,
|
|
},
|
|
sectionIcon: {
|
|
marginRight: 12,
|
|
},
|
|
sectionText: {
|
|
color: theme.foreground,
|
|
fontSize: 15,
|
|
fontFamily: fontWeights[500],
|
|
},
|
|
newBadge: {
|
|
backgroundColor: theme.primary,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 2,
|
|
borderRadius: 12,
|
|
},
|
|
newBadgeText: {
|
|
color: theme.background,
|
|
fontSize: 12,
|
|
fontFamily: fontWeights[600],
|
|
},
|
|
tasksHeader: {
|
|
flexDirection: 'row' as const,
|
|
alignItems: 'center' as const,
|
|
justifyContent: 'space-between' as const,
|
|
marginBottom: 8,
|
|
},
|
|
tasksTitle: {
|
|
color: theme.mutedForeground,
|
|
fontSize: 14,
|
|
fontFamily: fontWeights[600],
|
|
textTransform: 'uppercase' as const,
|
|
letterSpacing: 0.5,
|
|
},
|
|
addButton: {
|
|
width: 20,
|
|
height: 20,
|
|
borderRadius: 10,
|
|
backgroundColor: theme.mutedWithOpacity(0.2),
|
|
justifyContent: 'center' as const,
|
|
alignItems: 'center' as const,
|
|
},
|
|
taskItem: {
|
|
flexDirection: 'row' as const,
|
|
alignItems: 'center' as const,
|
|
paddingVertical: 10,
|
|
borderRadius: 6,
|
|
marginBottom: 4,
|
|
marginLeft: -12,
|
|
marginRight: -12,
|
|
},
|
|
taskIcon: {
|
|
marginRight: 12,
|
|
opacity: 0.7,
|
|
},
|
|
taskText: {
|
|
color: theme.foreground,
|
|
marginHorizontal: 12,
|
|
fontSize: 15,
|
|
fontFamily: fontWeights[500],
|
|
},
|
|
userSection: {
|
|
marginTop: 'auto' as const,
|
|
paddingTop: 10,
|
|
paddingHorizontal: 20,
|
|
borderTopWidth: 1,
|
|
borderTopColor: theme.border,
|
|
},
|
|
userInfo: {
|
|
flexDirection: 'row' as const,
|
|
alignItems: 'center' as const,
|
|
paddingVertical: 12,
|
|
},
|
|
userAvatar: {
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 16,
|
|
backgroundColor: theme.primary,
|
|
justifyContent: 'center' as const,
|
|
alignItems: 'center' as const,
|
|
marginRight: 12,
|
|
},
|
|
userInitial: {
|
|
color: theme.background,
|
|
fontSize: 14,
|
|
fontFamily: fontWeights[600],
|
|
},
|
|
userDetails: {
|
|
flex: 1,
|
|
},
|
|
userName: {
|
|
color: theme.foreground,
|
|
fontSize: 14,
|
|
fontFamily: fontWeights[500],
|
|
},
|
|
userEmail: {
|
|
color: theme.mutedForeground,
|
|
fontSize: 12,
|
|
marginTop: 1,
|
|
},
|
|
errorText: {
|
|
color: theme.destructive,
|
|
fontSize: 14,
|
|
textAlign: 'center' as const,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 12,
|
|
},
|
|
emptyText: {
|
|
color: theme.mutedForeground,
|
|
fontSize: 14,
|
|
textAlign: 'center' as const,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 12,
|
|
fontStyle: 'italic' as const,
|
|
},
|
|
selectedTaskItem: {
|
|
backgroundColor: theme.mutedWithOpacity(0.1),
|
|
borderRadius: 12,
|
|
},
|
|
selectedTaskText: {
|
|
color: theme.foreground,
|
|
},
|
|
settingsIcon: {
|
|
opacity: 0.6,
|
|
},
|
|
}));
|
|
|
|
if (!isVisible) return null;
|
|
|
|
// Render tasks section with real project data
|
|
const renderTasksSection = () => {
|
|
if (isLoading) {
|
|
return <SkeletonProjects count={3} />;
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<Body style={styles.errorText}>
|
|
Failed to load projects. Please try again.
|
|
</Body>
|
|
);
|
|
}
|
|
|
|
// Combine new chat project with regular projects
|
|
const allProjects = [];
|
|
|
|
// Add current selected project if it's from new chat session (temp or real)
|
|
if (isNewChatMode && selectedProject) {
|
|
allProjects.push({
|
|
id: selectedProject.id,
|
|
name: selectedProject.name,
|
|
isNewChat: true,
|
|
});
|
|
}
|
|
|
|
// Add regular projects - but skip if already added above to prevent duplicates
|
|
const selectedProjectId = isNewChatMode && selectedProject ? selectedProject.id : null;
|
|
allProjects.push(...projects
|
|
.filter(p => p.id !== selectedProjectId) // Prevent duplicates
|
|
.map(p => ({ ...p, isNewChat: false }))
|
|
);
|
|
|
|
if (allProjects.length === 0) {
|
|
return (
|
|
<Body style={styles.emptyText}>
|
|
No projects found. Create your first project to get started.
|
|
</Body>
|
|
);
|
|
}
|
|
|
|
return allProjects.map((project) => (
|
|
<TouchableOpacity
|
|
key={project.id}
|
|
ref={(ref) => { chatItemRefs.current[project.id] = ref; }}
|
|
style={[
|
|
styles.taskItem,
|
|
selectedProject?.id === project.id && styles.selectedTaskItem
|
|
]}
|
|
onPress={() => handleProjectSelect(project)}
|
|
onLongPress={() => handleProjectLongPress(project)}
|
|
delayLongPress={500}
|
|
>
|
|
<Body style={[
|
|
styles.taskText,
|
|
selectedProject?.id === project.id && styles.selectedTaskText
|
|
]}>
|
|
{project.name}
|
|
</Body>
|
|
</TouchableOpacity>
|
|
));
|
|
};
|
|
|
|
const handleProjectSelect = (project: any) => {
|
|
console.log('[LeftPanel] Project selected:', project.name, 'isNewChat:', project.isNewChat);
|
|
|
|
// If selecting a real project (not new chat), exit new chat mode
|
|
if (!project.isNewChat && project.id !== 'new-chat-temp') {
|
|
console.log('[LeftPanel] Selecting real project, exiting new chat mode');
|
|
setNewChatMode(false);
|
|
}
|
|
|
|
setSelectedProject(project);
|
|
console.log('[LeftPanel] Calling onClose...');
|
|
onClose(); // Close the panel after selection
|
|
};
|
|
|
|
const handleProjectLongPress = (project: any) => {
|
|
console.log('[LeftPanel] Long press on project:', project);
|
|
// Don't allow actions on new chat or temp projects
|
|
if (project.isNewChat || project.id === 'new-chat-temp') {
|
|
console.log('[LeftPanel] Ignoring long press on new chat project');
|
|
return;
|
|
}
|
|
|
|
const chatRef = chatItemRefs.current[project.id];
|
|
if (chatRef) {
|
|
chatRef.measure((x, y, width, height, pageX, pageY) => {
|
|
console.log('[LeftPanel] Setting project for actions:', { id: project.id, name: project.name });
|
|
setSelectedChatLayout({ x: pageX, y: pageY, width, height });
|
|
setProjectToDelete({ id: project.id, name: project.name });
|
|
setProjectToShare({ id: project.id, name: project.name, isPublic: project.is_public });
|
|
setActionModalVisible(true);
|
|
});
|
|
} else {
|
|
console.error('[LeftPanel] No chat ref found for project:', project.id);
|
|
}
|
|
};
|
|
|
|
const handleActionModalClose = () => {
|
|
setActionModalVisible(false);
|
|
setSelectedChatLayout(undefined);
|
|
setProjectToDelete(null);
|
|
setProjectToShare(null);
|
|
};
|
|
|
|
const handleActionModalDelete = () => {
|
|
console.log('[LeftPanel] Showing delete modal for:', projectToDelete?.name);
|
|
setActionModalVisible(false);
|
|
setDeleteModalVisible(true);
|
|
};
|
|
|
|
const handleActionModalShare = () => {
|
|
console.log('[LeftPanel] Showing share modal for:', projectToShare?.name);
|
|
setActionModalVisible(false);
|
|
setShareModalVisible(true);
|
|
};
|
|
|
|
const handleDeleteConfirm = async () => {
|
|
console.log('[LeftPanel] Delete confirm clicked for:', projectToDelete);
|
|
if (!projectToDelete) {
|
|
console.error('[LeftPanel] No project to delete!');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.log('[LeftPanel] Starting deletion of:', projectToDelete.id);
|
|
await deleteProjectMutation.mutateAsync(projectToDelete.id);
|
|
console.log('[LeftPanel] Deletion successful');
|
|
|
|
// If we're deleting the currently selected project, clear selection
|
|
if (selectedProject?.id === projectToDelete.id) {
|
|
setSelectedProject(null);
|
|
setNewChatMode(true);
|
|
}
|
|
|
|
setDeleteModalVisible(false);
|
|
setProjectToDelete(null);
|
|
} catch (error) {
|
|
console.error('[LeftPanel] Failed to delete project:', error);
|
|
// Error is already handled by the mutation
|
|
}
|
|
};
|
|
|
|
const handleDeleteCancel = () => {
|
|
setDeleteModalVisible(false);
|
|
setProjectToDelete(null);
|
|
};
|
|
|
|
const handleShareModalClose = () => {
|
|
setShareModalVisible(false);
|
|
setProjectToShare(null);
|
|
};
|
|
|
|
// Get user display info
|
|
const getUserDisplayName = () => {
|
|
if (!user?.email) return 'User';
|
|
return user.email.split('@')[0];
|
|
};
|
|
|
|
const getUserInitial = () => {
|
|
const name = getUserDisplayName();
|
|
return name.charAt(0).toUpperCase();
|
|
};
|
|
|
|
const handleSettingsDrawerOpen = () => {
|
|
setSettingsDrawerVisible(true);
|
|
};
|
|
|
|
const handleSettingsDrawerClose = () => {
|
|
setSettingsDrawerVisible(false);
|
|
};
|
|
|
|
return (
|
|
<View style={styles.panel}>
|
|
<View style={styles.header}>
|
|
<H3 style={styles.title}>Suna</H3>
|
|
<TouchableOpacity
|
|
onPress={() => {
|
|
console.log('[LeftPanel] Starting new chat from pen button');
|
|
resetNewChatSession(); // Reset the new chat session first
|
|
setSelectedProject(null); // Clear selected project
|
|
setNewChatMode(true);
|
|
onClose();
|
|
}}
|
|
>
|
|
<SquarePen size={22} strokeWidth={2} color={styles.title.color} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<ScrollView style={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
|
{/* Agent Playground */}
|
|
{/* <View style={styles.section}>
|
|
<TouchableOpacity style={styles.sectionItemWithBadge}>
|
|
<View style={styles.sectionItemLeft}>
|
|
<Bot size={20} color={styles.sectionText.color} style={styles.sectionIcon} />
|
|
<Body style={styles.sectionText}>Agent Playground</Body>
|
|
</View>
|
|
<View style={styles.newBadge}>
|
|
<Caption style={styles.newBadgeText}>New</Caption>
|
|
</View>
|
|
</TouchableOpacity>
|
|
</View> */}
|
|
|
|
{/* Marketplace */}
|
|
{/* <TouchableOpacity style={styles.sectionItemWithBadge}>
|
|
<View style={styles.sectionItemLeft}>
|
|
<Store size={20} color={styles.sectionText.color} style={styles.sectionIcon} />
|
|
<Body style={styles.sectionText}>Marketplace</Body>
|
|
</View>
|
|
<View style={styles.newBadge}>
|
|
<Caption style={styles.newBadgeText}>New</Caption>
|
|
</View>
|
|
</TouchableOpacity> */}
|
|
|
|
{/* Projects (previously Tasks) */}
|
|
<View style={styles.section}>
|
|
<View style={styles.tasksHeader}>
|
|
<Caption style={styles.tasksTitle}>Chats</Caption>
|
|
</View>
|
|
|
|
{renderTasksSection()}
|
|
</View>
|
|
</ScrollView>
|
|
|
|
{/* User Section */}
|
|
<View style={styles.userSection}>
|
|
<TouchableOpacity style={styles.userInfo} onPress={handleSettingsDrawerOpen}>
|
|
<View style={styles.userAvatar}>
|
|
<Caption style={styles.userInitial}>{getUserInitial()}</Caption>
|
|
</View>
|
|
<View style={styles.userDetails}>
|
|
<Body style={styles.userName}>{getUserDisplayName()}</Body>
|
|
<Caption style={styles.userEmail}>{user?.email || 'No email'}</Caption>
|
|
</View>
|
|
<ChevronsUpDown size={16} color={styles.title.color} style={styles.settingsIcon} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Chat Action Modal */}
|
|
<ChatActionModal
|
|
visible={actionModalVisible}
|
|
chatName={projectToDelete?.name || ''}
|
|
sourceLayout={selectedChatLayout}
|
|
onClose={handleActionModalClose}
|
|
onDelete={handleActionModalDelete}
|
|
onShare={handleActionModalShare}
|
|
/>
|
|
|
|
{/* Share Modal */}
|
|
<ShareModal
|
|
visible={shareModalVisible}
|
|
onClose={handleShareModalClose}
|
|
projectId={projectToShare?.id || ''}
|
|
projectName={projectToShare?.name || ''}
|
|
isPublic={projectToShare?.isPublic || false}
|
|
/>
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
<DeleteConfirmationModal
|
|
visible={deleteModalVisible}
|
|
projectName={projectToDelete?.name || ''}
|
|
isDeleting={deleteProjectMutation.isPending}
|
|
onClose={handleDeleteCancel}
|
|
onConfirm={handleDeleteConfirm}
|
|
/>
|
|
|
|
{/* Settings Drawer */}
|
|
<SettingsDrawer
|
|
visible={settingsDrawerVisible}
|
|
onClose={handleSettingsDrawerClose}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|