diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index c422cab4..72fd714a 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -7,6 +7,7 @@ import { routeTree } from "./routeTree.gen"; import { useOpenSecret, OpenSecretProvider } from "@opensecret/react"; import { OpenAIProvider } from "./ai/OpenAIContext"; import { LocalStateProvider } from "./state/LocalStateContext"; +import { ProjectsProvider } from "./state/ProjectsContext"; import { ErrorFallback } from "./components/ErrorFallback"; import { NotFoundFallback } from "./components/NotFoundFallback"; import { BillingServiceProvider } from "./components/BillingServiceProvider"; @@ -96,6 +97,7 @@ export default function App() { }} > + @@ -110,6 +112,7 @@ export default function App() { + diff --git a/frontend/src/components/ChatContextMenu.tsx b/frontend/src/components/ChatContextMenu.tsx new file mode 100644 index 00000000..1081237f --- /dev/null +++ b/frontend/src/components/ChatContextMenu.tsx @@ -0,0 +1,176 @@ +import { useState } from "react"; +import { + CheckSquare, + ChevronLeft, + ChevronRight, + Folder, + FolderMinus, + FolderPlus, + MoreHorizontal, + Pencil, + Plus, + Trash2 +} from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; + +interface ChatContextMenuProps { + chatId: string; + isMobile: boolean; + // All projects for "Move to project" submenu + projects: { id: string; name: string }[]; + // If set, shows "Remove from {projectName}" and grays out current project in submenu + currentProjectName?: string; + currentProjectId?: string; + // Optional callbacks — item only rendered when provided + onSelect?: () => void; + onRename?: () => void; + onMoveToProject?: (projectId: string) => void; + onRemoveFromProject?: () => void; + onDelete?: () => void; +} + +export function ChatContextMenu({ + chatId, + isMobile, + projects, + currentProjectName, + currentProjectId, + onSelect, + onRename, + onMoveToProject, + onRemoveFromProject, + onDelete +}: ChatContextMenuProps) { + const [showProjectSubmenu, setShowProjectSubmenu] = useState(false); + + return ( + !open && setShowProjectSubmenu(false)}> + + + + +
+ {/* Main menu layer — in-flow when active, absolute when hidden */} +
+ {onSelect && ( + + + Select + + )} + {onRename && ( + + + Rename chat + + )} + {onMoveToProject && ( + { + e.preventDefault(); + e.stopPropagation(); + setShowProjectSubmenu(true); + }} + onSelect={(e) => e.preventDefault()} + > + + Move to project + + + )} + {onRemoveFromProject && currentProjectName && ( + + + Remove from {currentProjectName} + + )} + {onDelete && ( + + + Delete chat + + )} +
+ + {/* Project submenu layer — in-flow when active, absolute when hidden */} + {onMoveToProject && ( +
+ { + e.preventDefault(); + e.stopPropagation(); + setShowProjectSubmenu(false); + }} + onSelect={(e) => e.preventDefault()} + > + + Back + + + { + window.dispatchEvent( + new CustomEvent("createprojectforchat", { + detail: { chatId } + }) + ); + }} + > + + New project + + {projects.length > 0 && } +
+ {projects.map((project) => { + const isCurrent = project.id === currentProjectId; + return ( + !isCurrent && onMoveToProject(project.id)} + onSelect={(e) => isCurrent && e.preventDefault()} + disabled={isCurrent} + > + + {project.name} + + ); + })} +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/ChatHistoryList.tsx b/frontend/src/components/ChatHistoryList.tsx index d0d08fd7..ca824f47 100644 --- a/frontend/src/components/ChatHistoryList.tsx +++ b/frontend/src/components/ChatHistoryList.tsx @@ -1,13 +1,12 @@ import { useState, useMemo, useCallback, useEffect, useRef, useContext } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { - MoreHorizontal, - Trash, - Pencil, ChevronDown, ChevronRight, - CheckSquare, - RefreshCw + MoreHorizontal, + Pencil, + RefreshCw, + Trash } from "lucide-react"; import { DropdownMenu, @@ -15,6 +14,8 @@ import { DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { ChatContextMenu } from "@/components/ChatContextMenu"; +import { useProjects } from "@/state/useProjects"; import { Checkbox } from "@/components/ui/checkbox"; import { RenameChatDialog } from "@/components/RenameChatDialog"; import { DeleteChatDialog } from "@/components/DeleteChatDialog"; @@ -33,6 +34,8 @@ interface ChatHistoryListProps { selectedIds: Set; onSelectionChange: (ids: Set) => void; containerRef?: React.RefObject; + excludeChatIds?: Set; + onConversationsLoaded?: (conversations: Conversation[]) => void; } interface Conversation { @@ -60,7 +63,9 @@ export function ChatHistoryList({ onExitSelectionMode, selectedIds, onSelectionChange, - containerRef + containerRef, + excludeChatIds, + onConversationsLoaded }: ChatHistoryListProps) { const openai = useOpenAI(); const opensecret = useOpenSecret(); @@ -73,8 +78,33 @@ export function ChatHistoryList({ const [isBulkDeleting, setIsBulkDeleting] = useState(false); const [selectedChat, setSelectedChat] = useState<{ id: string; title: string } | null>(null); const [isArchivedExpanded, setIsArchivedExpanded] = useState(false); + const [isRecentsExpanded, setIsRecentsExpanded] = useState(() => { + try { + const stored = localStorage.getItem("maple_recents_expanded"); + if (stored !== null) return stored === "true"; + return true; + } catch { + return true; + } + }); + + const toggleRecentsExpanded = useCallback(() => { + setIsRecentsExpanded((prev) => { + const next = !prev; + localStorage.setItem("maple_recents_expanded", String(next)); + return next; + }); + }, []); const longPressTimerRef = useRef | null>(null); + // Projects integration + const { + projects, + getProjectForChat, + assignChatToProject, + removeChatFromProject + } = useProjects(); + // Pagination states const [oldestConversationId, setOldestConversationId] = useState(); const [hasMoreConversations, setHasMoreConversations] = useState(false); @@ -483,16 +513,28 @@ export function ChatHistoryList({ }); // Filter conversations based on search query + // Notify parent of conversations for ProjectsList to use + useEffect(() => { + if (onConversationsLoaded && conversations.length > 0) { + onConversationsLoaded(conversations); + } + }, [conversations, onConversationsLoaded]); + const filteredConversations = useMemo(() => { if (!conversations) return []; - if (!searchQuery.trim()) return conversations; + // Filter out chats assigned to projects + let convs = conversations; + if (excludeChatIds && excludeChatIds.size > 0) { + convs = convs.filter((c) => !excludeChatIds.has(c.id)); + } + if (!searchQuery.trim()) return convs; const normalizedQuery = searchQuery.trim().toLowerCase(); - return conversations.filter((conv: Conversation) => { + return convs.filter((conv: Conversation) => { const title = conv.metadata?.title || "Untitled Chat"; return title.toLowerCase().includes(normalizedQuery); }); - }, [conversations, searchQuery]); + }, [conversations, searchQuery, excludeChatIds]); // Filter archived chats based on search query const filteredArchivedChats = useMemo(() => { @@ -824,7 +866,29 @@ export function ChatHistoryList({
- {filteredConversations.map((conv: Conversation, index: number) => { + {/* Recents header */} + {filteredConversations.length > 0 && ( + + )} + {(isRecentsExpanded || (searchQuery.trim() && filteredConversations.length > 0)) && filteredConversations.map((conv: Conversation, index: number) => { const title = conv.metadata?.title || "Untitled Chat"; const isActive = conv.id === currentChatId; const isSelected = selectedIds.has(conv.id); @@ -880,35 +944,18 @@ export function ChatHistoryList({
{!isSelectionMode && ( - - - - - - onSelectionChange(new Set([conv.id]))}> - - Select - - handleOpenRenameDialog(conv)}> - - Rename Chat - - handleOpenDeleteDialog(conv)}> - - Delete Chat - - - + onSelectionChange(new Set([conv.id]))} + onRename={() => handleOpenRenameDialog(conv)} + onDelete={() => handleOpenDeleteDialog(conv)} + onMoveToProject={(projectId) => assignChatToProject(conv.id, projectId)} + onRemoveFromProject={() => removeChatFromProject(conv.id)} + /> )} {!isSelectionMode && (
@@ -918,7 +965,7 @@ export function ChatHistoryList({ })} {/* Loading indicator for pagination */} - {isLoadingMore && ( + {(isRecentsExpanded || (searchQuery.trim() && filteredConversations.length > 0)) && isLoadingMore && (
@@ -1027,3 +1074,5 @@ export function ChatHistoryList({ ); } + +export type { Conversation }; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 7b4ccd19..2a24cf51 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -10,11 +10,16 @@ import { import { Button } from "./ui/button"; import { useLocation, useRouter } from "@tanstack/react-router"; import { ChatHistoryList } from "./ChatHistoryList"; +import type { Conversation } from "./ChatHistoryList"; import { AccountMenu } from "./AccountMenu"; import { useRef, useEffect, KeyboardEvent, useCallback, useLayoutEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { cn, useClickOutside, useIsMobile } from "@/utils/utils"; import { Input } from "./ui/input"; import { useLocalState } from "@/state/useLocalState"; +import { useProjects } from "@/state/useProjects"; +import { ProjectsList } from "./projects/ProjectsList"; +import { CreateProjectDialog } from "./projects/CreateProjectDialog"; export function Sidebar({ chatId, @@ -28,7 +33,42 @@ export function Sidebar({ const router = useRouter(); const location = useLocation(); const { searchQuery, setSearchQuery, isSearchVisible, setIsSearchVisible } = useLocalState(); + const { getAllAssignedChatIds, getDeletedChatIds, createProject, assignChatToProject } = useProjects(); const searchInputRef = useRef(null); + const queryClient = useQueryClient(); + + // Conversations from ChatHistoryList (shared with ProjectsList) + // Seed from query cache to prevent flash of empty state on route changes + const [conversations, setConversations] = useState(() => { + const cached = queryClient.getQueryData(["conversations"]); + return cached || []; + }); + const assignedIds = getAllAssignedChatIds(); + const deletedIds = getDeletedChatIds(); + const excludeChatIds = new Set([...assignedIds, ...deletedIds]); + + // Create project for chat (triggered from context menu "New project") + const [createProjectForChatId, setCreateProjectForChatId] = useState(null); + + // Listen for createprojectforchat events from context menu + useEffect(() => { + const handler = (e: Event) => { + const { chatId } = (e as CustomEvent).detail; + if (chatId) setCreateProjectForChatId(chatId); + }; + window.addEventListener("createprojectforchat", handler); + return () => window.removeEventListener("createprojectforchat", handler); + }, []); + + const handleCreateProjectForChat = useCallback( + (name: string) => { + if (!createProjectForChatId) return; + const projectId = createProject(name); + assignChatToProject(createProjectForChatId, projectId); + setCreateProjectForChatId(null); + }, + [createProjectForChatId, createProject, assignChatToProject] + ); // Multi-select state const [isSelectionMode, setIsSelectionMode] = useState(false); @@ -66,14 +106,18 @@ export function Sidebar({ window.dispatchEvent(new Event("newchat")); document.getElementById("message")?.focus(); } else if (location.pathname === "/") { - // Already on home with no conversation_id, just focus + // Already on home with no conversation_id — reset state (clears project selector etc.) + window.dispatchEvent(new Event("newchat")); document.getElementById("message")?.focus(); } else { try { // Navigate to home without any query params await router.navigate({ to: `/` }); - // Ensure element is available after navigation - setTimeout(() => document.getElementById("message")?.focus(), 0); + // Reset state after navigation + setTimeout(() => { + window.dispatchEvent(new Event("newchat")); + document.getElementById("message")?.focus(); + }, 0); } catch (error) { console.error("Navigation failed:", error); } @@ -217,8 +261,7 @@ export function Sidebar({ ) : ( - <> -

History

+
- +
)}
{isSearchVisible && ( @@ -255,6 +298,12 @@ export function Sidebar({
)}
+ + {/* Create project dialog triggered from chat context menu "New project" */} + !open && setCreateProjectForChatId(null)} + onSubmit={handleCreateProjectForChat} + /> ); } diff --git a/frontend/src/components/UnifiedChat.tsx b/frontend/src/components/UnifiedChat.tsx index 830958ee..0aa722ee 100644 --- a/frontend/src/components/UnifiedChat.tsx +++ b/frontend/src/components/UnifiedChat.tsx @@ -39,7 +39,9 @@ import { Maximize2, Minimize2, Volume2, - Square + Square, + Folder, + ChevronDown } from "lucide-react"; import RecordRTC from "recordrtc"; import { useQueryClient } from "@tanstack/react-query"; @@ -69,9 +71,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { isTauri } from "@/utils/platform"; +import { useProjects } from "@/state/useProjects"; import type { InputTextContent, OutputTextContent, @@ -875,6 +879,7 @@ export function UnifiedChat() { const isTauriEnv = isTauri(); const queryClient = useQueryClient(); const { playbackError, clearPlaybackError } = useTTS(); + const { projects } = useProjects(); // Track chatId from URL - use state so we can update it const [chatId, setChatId] = useState(() => { @@ -926,6 +931,16 @@ export function UnifiedChat() { return localStorage.getItem("webSearchEnabled") === "true"; }); + // Project selector for new chats + const [selectedProjectId, setSelectedProjectId] = useState(() => { + const params = new URLSearchParams(window.location.search); + return params.get("project_id") || null; + }); + const selectedProject = useMemo( + () => projects.find((p) => p.id === selectedProjectId) || null, + [projects, selectedProjectId] + ); + // Fullscreen mode for power users - persisted in localStorage const [isFullscreen, setIsFullscreen] = useState(() => { return localStorage.getItem("chatFullscreen") === "true"; @@ -1144,6 +1159,16 @@ export function UnifiedChat() { clearAllAttachments(); // Reset scroll tracking prevMessageCountRef.current = 0; + // Check if navigating from a project page + const projectParam = new URLSearchParams(window.location.search).get("project_id"); + setSelectedProjectId(projectParam || null); + if (projectParam) { + // Clean up the URL param + const cleaned = new URLSearchParams(window.location.search); + cleaned.delete("project_id"); + const newUrl = cleaned.toString() ? `/?${cleaned.toString()}` : "/"; + window.history.replaceState(null, "", newUrl); + } }; // Handle conversation selection from sidebar @@ -2346,6 +2371,13 @@ export function UnifiedChat() { // Trigger sidebar refresh to show the new conversation window.dispatchEvent(new Event("conversationcreated")); + + // Auto-assign to project if one is selected + if (selectedProjectId) { + window.dispatchEvent(new CustomEvent("assignchattoproject", { + detail: { chatId: conversationId, projectId: selectedProjectId } + })); + } } // Create abort controller for this request @@ -3000,6 +3032,55 @@ export function UnifiedChat() { + + {/* Project selector — only for new chats */} + {!chatId && ( + + + + + + setSelectedProjectId(null)}> + + No project + + {projects.length > 0 && } + {projects.map((project) => ( + setSelectedProjectId(project.id)} + > + + + {project.name} + + ))} + + + )}
@@ -3243,6 +3324,55 @@ export function UnifiedChat() { + + {/* Project selector — only for new chats */} + {!chatId && ( + + + + + + setSelectedProjectId(null)}> + + No project + + {projects.length > 0 && } + {projects.map((project) => ( + setSelectedProjectId(project.id)} + > + + + {project.name} + + ))} + + + )}
diff --git a/frontend/src/components/projects/CreateProjectDialog.tsx b/frontend/src/components/projects/CreateProjectDialog.tsx new file mode 100644 index 00000000..a600ed8d --- /dev/null +++ b/frontend/src/components/projects/CreateProjectDialog.tsx @@ -0,0 +1,89 @@ +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +interface CreateProjectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (name: string) => void; + mode?: "create" | "rename"; + initialName?: string; +} + +export function CreateProjectDialog({ + open, + onOpenChange, + onSubmit, + mode = "create", + initialName = "" +}: CreateProjectDialogProps) { + const [name, setName] = useState(""); + + useEffect(() => { + if (open) { + setName(mode === "rename" ? initialName : ""); + } + }, [open, mode, initialName]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) return; + onSubmit(trimmed); + onOpenChange(false); + }; + + const isCreate = mode === "create"; + + return ( + + + + {isCreate ? "Create project" : "Rename project"} + + {isCreate + ? "Give your project a name. You can add instructions and files after creating it." + : "Enter a new name for this project."} + + +
+
+ + setName(e.target.value)} + placeholder="Name your project" + required + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.currentTarget.form?.requestSubmit(); + } + }} + /> +
+ + + + +
+
+
+ ); +} diff --git a/frontend/src/components/projects/CustomInstructionsDialog.tsx b/frontend/src/components/projects/CustomInstructionsDialog.tsx new file mode 100644 index 00000000..68174e1b --- /dev/null +++ b/frontend/src/components/projects/CustomInstructionsDialog.tsx @@ -0,0 +1,66 @@ +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; + +interface CustomInstructionsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + currentInstructions: string; + onSave: (instructions: string) => void; +} + +export function CustomInstructionsDialog({ + open, + onOpenChange, + currentInstructions, + onSave +}: CustomInstructionsDialogProps) { + const [instructions, setInstructions] = useState(""); + + useEffect(() => { + if (open) { + setInstructions(currentInstructions); + } + }, [open, currentInstructions]); + + const handleSave = () => { + onSave(instructions); + onOpenChange(false); + }; + + return ( + + + + Custom instructions + + Set context and customize how Maple responds in this project. + + +
+