From 3ee8b019afc460ad8795a0e4c7e83f1360127471 Mon Sep 17 00:00:00 2001 From: marks Date: Wed, 11 Feb 2026 17:24:22 -0600 Subject: [PATCH 1/3] feat: add Projects prototype with localStorage persistence Build the complete Projects UI/UX flow as a frontend-only prototype using localStorage for data persistence. This allows evaluating the design and interactions before committing to a full backend implementation. - Add ProjectsContext/useProjects for state management (localStorage) - Add project CRUD: create, rename, delete with confirmation dialogs - Add project detail page with chat input, custom instructions, and files - Add collapsible Projects and Recents sections in sidebar - Add unified ChatContextMenu shared across all chat context menus - Add "Move to project" drill-down submenu with animated slide transition - Add chat rename/delete support from both sidebar and project page - Auto-assign chats to projects via URL param after conversation creation - Filter project chats from Recents list, track deleted project chats --- frontend/src/app.tsx | 3 + frontend/src/components/ChatContextMenu.tsx | 166 +++++ frontend/src/components/ChatHistoryList.tsx | 128 ++-- frontend/src/components/Sidebar.tsx | 60 +- frontend/src/components/UnifiedChat.tsx | 11 + .../projects/CreateProjectDialog.tsx | 89 +++ .../projects/CustomInstructionsDialog.tsx | 66 ++ .../projects/DeleteProjectDialog.tsx | 57 ++ .../components/projects/ProjectDetailPage.tsx | 596 ++++++++++++++++++ .../src/components/projects/ProjectsList.tsx | 434 +++++++++++++ .../components/projects/RemoveFileDialog.tsx | 48 ++ frontend/src/routeTree.gen.ts | 29 +- .../src/routes/_auth.project.$projectId.tsx | 11 + frontend/src/state/ProjectsContext.tsx | 273 ++++++++ frontend/src/state/useProjects.ts | 10 + 15 files changed, 1937 insertions(+), 44 deletions(-) create mode 100644 frontend/src/components/ChatContextMenu.tsx create mode 100644 frontend/src/components/projects/CreateProjectDialog.tsx create mode 100644 frontend/src/components/projects/CustomInstructionsDialog.tsx create mode 100644 frontend/src/components/projects/DeleteProjectDialog.tsx create mode 100644 frontend/src/components/projects/ProjectDetailPage.tsx create mode 100644 frontend/src/components/projects/ProjectsList.tsx create mode 100644 frontend/src/components/projects/RemoveFileDialog.tsx create mode 100644 frontend/src/routes/_auth.project.$projectId.tsx create mode 100644 frontend/src/state/ProjectsContext.tsx create mode 100644 frontend/src/state/useProjects.ts 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..f1b3d2b5 --- /dev/null +++ b/frontend/src/components/ChatContextMenu.tsx @@ -0,0 +1,166 @@ +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; + // Available projects for "Move to project" submenu + projects: { id: string; name: string }[]; + // If set, shows "Remove from {projectName}" + currentProjectName?: 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, + 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) => ( + onMoveToProject(project.id)}> + + {project.name} + + ))} +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/ChatHistoryList.tsx b/frontend/src/components/ChatHistoryList.tsx index d0d08fd7..0ebab4a0 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,17 @@ 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 +964,7 @@ export function ChatHistoryList({ })} {/* Loading indicator for pagination */} - {isLoadingMore && ( + {(isRecentsExpanded || (searchQuery.trim() && filteredConversations.length > 0)) && isLoadingMore && (
@@ -1027,3 +1073,5 @@ export function ChatHistoryList({ ); } + +export type { Conversation }; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 7b4ccd19..42a8d91c 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); @@ -217,8 +257,7 @@ export function Sidebar({ ) : ( - <> -

History

+
- +
)}
{isSearchVisible && ( @@ -255,6 +294,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..75faba28 100644 --- a/frontend/src/components/UnifiedChat.tsx +++ b/frontend/src/components/UnifiedChat.tsx @@ -2346,6 +2346,17 @@ export function UnifiedChat() { // Trigger sidebar refresh to show the new conversation window.dispatchEvent(new Event("conversationcreated")); + + // Auto-assign to project if project_id param is present + const projectParam = new URLSearchParams(window.location.search).get("project_id"); + if (projectParam) { + window.dispatchEvent(new CustomEvent("assignchattoproject", { + detail: { chatId: conversationId, projectId: projectParam } + })); + const cleaned = new URLSearchParams(window.location.search); + cleaned.delete("project_id"); + window.history.replaceState(null, "", `${window.location.pathname}?${cleaned.toString()}`); + } } // Create abort controller for this request 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. + + +
+