diff --git a/frontend/bun.lock b/frontend/bun.lock index 847fabcdc..890d40cc7 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -5,7 +5,7 @@ "": { "name": "maple", "dependencies": { - "@opensecret/react": "1.5.3", + "@opensecret/react": "3.0.2", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.3.3", @@ -224,7 +224,7 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@opensecret/react": ["@opensecret/react@1.5.3", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-ObEm+9B9X2bzEiEWQ0B+wrbVTy2+gOFOYgjdVFD3sOEuwPmripbi9U0A1VAdjFfqmunGkhjco8RGMebKo2CrTA=="], + "@opensecret/react": ["@opensecret/react@3.0.2", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-cSg6/JiNEW4QWZlhLpAY9OtsBwXpuhafAY/okyQUhz40S6gPk9l8+u0KecGF/4/8imGdhPqsqGHfoOiqZZ0oWg=="], "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "@peculiar/asn1-x509": "^2.3.15", "@peculiar/asn1-x509-attr": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-B+DoudF+TCrxoJSTjjcY8Mmu+lbv8e7pXGWrhNp2/EGJp9EEcpzjBCar7puU57sGifyzaRVM03oD5L7t7PghQg=="], diff --git a/frontend/package.json b/frontend/package.json index 52e8b8ff7..40618662b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,7 @@ "mdast-util-gfm-autolink-literal": "2.0.0" }, "dependencies": { - "@opensecret/react": "1.5.3", + "@opensecret/react": "3.0.2", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/frontend/src/components/AccountMenu.tsx b/frontend/src/components/AccountMenu.tsx index 3c20b881c..c0141fd94 100644 --- a/frontend/src/components/AccountMenu.tsx +++ b/frontend/src/components/AccountMenu.tsx @@ -105,6 +105,7 @@ function ConfirmDeleteDialog() { export function AccountMenu() { const os = useOpenSecret(); + const queryClient = useQueryClient(); const router = useRouter(); const { billingStatus } = useLocalState(); const [isPortalLoading, setIsPortalLoading] = useState(false); @@ -274,6 +275,7 @@ export function AccountMenu() { // Sign out from OpenSecret await os.signOut(); + queryClient.clear(); // Navigate after everything is done await router.invalidate(); diff --git a/frontend/src/components/ChatHistoryList.tsx b/frontend/src/components/ChatHistoryList.tsx index d0d08fd71..bb0d2b8bf 100644 --- a/frontend/src/components/ChatHistoryList.tsx +++ b/frontend/src/components/ChatHistoryList.tsx @@ -7,22 +7,38 @@ import { ChevronDown, ChevronRight, CheckSquare, - RefreshCw + RefreshCw, + Folder, + FolderPlus, + FolderInput, + Pin, + PinOff } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Checkbox } from "@/components/ui/checkbox"; import { RenameChatDialog } from "@/components/RenameChatDialog"; import { DeleteChatDialog } from "@/components/DeleteChatDialog"; import { BulkDeleteDialog } from "@/components/BulkDeleteDialog"; -import { useOpenAI } from "@/ai/useOpenAi"; -import { useOpenSecret } from "@opensecret/react"; +import { + useOpenSecret, + type Conversation, + type ConversationProjectListItem +} from "@opensecret/react"; import { useRouter } from "@tanstack/react-router"; import { LocalStateContext } from "@/state/LocalStateContext"; +import { ConversationProjectDialog } from "@/components/ConversationProjectDialog"; +import { DeleteConversationProjectDialog } from "@/components/DeleteConversationProjectDialog"; +import { MoveChatsDialog } from "@/components/MoveChatsDialog"; +import { listAllConversationProjects, listAllConversations } from "@/utils/paginatedLists"; interface ChatHistoryListProps { currentChatId?: string; @@ -35,16 +51,6 @@ interface ChatHistoryListProps { containerRef?: React.RefObject; } -interface Conversation { - id: string; - object: "conversation"; - created_at: number; - metadata?: { - title?: string; - [key: string]: unknown; - }; -} - interface ArchivedChat { id: string; title: string; @@ -62,16 +68,24 @@ export function ChatHistoryList({ onSelectionChange, containerRef }: ChatHistoryListProps) { - const openai = useOpenAI(); const opensecret = useOpenSecret(); const router = useRouter(); const queryClient = useQueryClient(); const localState = useContext(LocalStateContext); + const { selectedProjectId, setSelectedProjectId } = localState; + const userId = opensecret.auth.user?.user.id; const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); + const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false); const [isBulkDeleting, setIsBulkDeleting] = useState(false); + const [isBulkMoving, setIsBulkMoving] = useState(false); + const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false); + const [projectDialogMode, setProjectDialogMode] = useState<"create" | "rename">("create"); + const [isDeleteProjectDialogOpen, setIsDeleteProjectDialogOpen] = useState(false); const [selectedChat, setSelectedChat] = useState<{ id: string; title: string } | null>(null); + const [selectedProject, setSelectedProject] = useState(null); + const [expandedProjectId, setExpandedProjectId] = useState(selectedProjectId); const [isArchivedExpanded, setIsArchivedExpanded] = useState(false); const longPressTimerRef = useRef | null>(null); @@ -94,6 +108,21 @@ export function ChatHistoryList({ const pullIndicatorRef = useRef(null); const refreshIconRef = useRef(null); + const getConversationTitle = useCallback((conversation: Conversation) => { + const rawTitle = conversation.metadata?.title; + return typeof rawTitle === "string" && rawTitle.trim() ? rawTitle : "Untitled Chat"; + }, []); + + const invalidateConversationData = useCallback(async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["conversations"] }), + queryClient.invalidateQueries({ queryKey: ["pinnedConversations"] }), + queryClient.invalidateQueries({ queryKey: ["projectConversations"] }), + queryClient.invalidateQueries({ queryKey: ["conversationProjects"] }), + queryClient.invalidateQueries({ queryKey: ["conversationProject"] }) + ]); + }, [queryClient]); + const applyPullStyles = useCallback(() => { const distance = pullDistanceRef.current; @@ -156,7 +185,8 @@ export function ChatHistoryList({ try { // Load initial 20 conversations (newest first by default) const response = await opensecret.listConversations({ - limit: 20 + limit: 20, + unassigned_project: true }); const loadedConversations = response.data || []; @@ -194,7 +224,8 @@ export function ChatHistoryList({ // Fetch latest conversations to detect new ones or metadata changes // Default order is desc (newest first), fetches up to 20 conversations const response = await opensecret.listConversations({ - limit: 20 // Fetch up to 20 to catch any new conversations + limit: 20, // Fetch up to 20 to catch any new conversations + unassigned_project: true }); const newConversations = response.data || []; @@ -398,7 +429,8 @@ export function ChatHistoryList({ // Fetch next 10 older conversations using the oldest conversation ID we have const response = await opensecret.listConversations({ limit: 10, - after: oldestConversationId + after: oldestConversationId, + unassigned_project: true }); const olderConversations = response.data || []; @@ -482,72 +514,166 @@ export function ChatHistoryList({ retry: false }); - // Filter conversations based on search query - const filteredConversations = useMemo(() => { - if (!conversations) return []; - if (!searchQuery.trim()) return conversations; + const { data: conversationProjects = [] } = useQuery({ + queryKey: ["conversationProjects", userId], + queryFn: () => listAllConversationProjects(opensecret), + enabled: !!userId + }); + + const { data: expandedProjectConversations = [] } = useQuery({ + queryKey: ["projectConversations", userId, expandedProjectId], + queryFn: () => { + if (!expandedProjectId) return []; + return listAllConversations(opensecret, { + project_id: expandedProjectId + }); + }, + enabled: !!userId && !!expandedProjectId + }); + + const { data: pinnedConversations = [] } = useQuery({ + queryKey: ["pinnedConversations", userId], + queryFn: () => + listAllConversations(opensecret, { + pinned: true + }), + enabled: !!userId + }); + + useEffect(() => { + if ( + selectedProjectId && + conversationProjects.length > 0 && + !conversationProjects.some((project) => project.id === selectedProjectId) + ) { + setSelectedProjectId(null); + } + }, [selectedProjectId, conversationProjects, setSelectedProjectId]); + + useEffect(() => { + if (selectedProjectId) { + setExpandedProjectId(selectedProjectId); + } + }, [selectedProjectId]); - const normalizedQuery = searchQuery.trim().toLowerCase(); - return conversations.filter((conv: Conversation) => { - const title = conv.metadata?.title || "Untitled Chat"; - return title.toLowerCase().includes(normalizedQuery); + useEffect(() => { + if ( + expandedProjectId && + conversationProjects.length > 0 && + !conversationProjects.some((project) => project.id === expandedProjectId) + ) { + setExpandedProjectId(null); + } + }, [expandedProjectId, conversationProjects]); + + const normalizedQuery = searchQuery.trim().toLowerCase(); + + const filteredProjects = useMemo(() => { + if (!normalizedQuery) return conversationProjects; + + return conversationProjects.filter((project) => { + if (project.name.toLowerCase().includes(normalizedQuery)) { + return true; + } + + if (project.id !== expandedProjectId) { + return false; + } + + return expandedProjectConversations.some((conversation) => + getConversationTitle(conversation).toLowerCase().includes(normalizedQuery) + ); }); - }, [conversations, searchQuery]); + }, [ + conversationProjects, + expandedProjectConversations, + expandedProjectId, + getConversationTitle, + normalizedQuery + ]); + + const filteredExpandedProjectConversations = useMemo(() => { + if (!normalizedQuery) return expandedProjectConversations; + + return expandedProjectConversations.filter((conversation) => + getConversationTitle(conversation).toLowerCase().includes(normalizedQuery) + ); + }, [expandedProjectConversations, getConversationTitle, normalizedQuery]); + + const filteredPinnedConversations = useMemo(() => { + if (!normalizedQuery) return pinnedConversations; + + return pinnedConversations.filter((conversation) => + getConversationTitle(conversation).toLowerCase().includes(normalizedQuery) + ); + }, [getConversationTitle, normalizedQuery, pinnedConversations]); + + const filteredRecentConversations = useMemo(() => { + const filtered = conversations.filter( + (conversation) => !conversation.pinned && !conversation.project_id + ); + + if (!normalizedQuery) return filtered; + + return filtered.filter((conversation) => + getConversationTitle(conversation).toLowerCase().includes(normalizedQuery) + ); + }, [conversations, getConversationTitle, normalizedQuery]); // Filter archived chats based on search query const filteredArchivedChats = useMemo(() => { if (!archivedChats) return []; - if (!searchQuery.trim()) return archivedChats; + if (!normalizedQuery) return archivedChats; - const normalizedQuery = searchQuery.trim().toLowerCase(); return archivedChats.filter((chat) => chat.title.toLowerCase().includes(normalizedQuery)); - }, [archivedChats, searchQuery]); + }, [archivedChats, normalizedQuery]); // Auto-expand archived section when searching with results useEffect(() => { - if (searchQuery.trim() && filteredArchivedChats.length > 0) { + if (normalizedQuery && filteredArchivedChats.length > 0) { setIsArchivedExpanded(true); } - }, [searchQuery, filteredArchivedChats.length]); + }, [normalizedQuery, filteredArchivedChats.length]); + + const dispatchConversationMetadataUpdated = useCallback( + (conversationId: string, updates: Record) => { + window.dispatchEvent( + new CustomEvent("conversationmetadataupdated", { + detail: { conversationId, ...updates } + }) + ); + }, + [] + ); // Handle conversation deletion via API const handleDeleteConversation = useCallback( async (conversationId: string) => { - // Check if it's an archived chat const isArchived = archivedChats?.some((chat) => chat.id === conversationId); if (isArchived) { - // Handle archived chat deletion if (!localState?.deleteChat) return; try { await localState.deleteChat(conversationId); - // Refresh archived chats list - queryClient.invalidateQueries({ queryKey: ["archivedChats"] }); + await queryClient.invalidateQueries({ queryKey: ["archivedChats"] }); - // If deleting the current chat, navigate to home if (conversationId === currentChatId) { router.navigate({ to: "/" }); + setSelectedProjectId(null); } } catch (error) { console.error("Error deleting archived chat:", error); } } else { - // Handle API conversation deletion - if (!openai) return; try { - await openai.conversations.delete(conversationId); - - // Remove from local state immediately + await opensecret.deleteConversation(conversationId); setConversations((prev) => prev.filter((conv) => conv.id !== conversationId)); + await invalidateConversationData(); - // If deleting the current conversation, navigate to home if (conversationId === currentChatId) { - // Clear URL and start fresh const params = new URLSearchParams(window.location.search); params.delete("conversation_id"); window.history.replaceState({}, "", params.toString() ? `/?${params}` : "/"); - - // Dispatch event to clear UnifiedChat window.dispatchEvent(new Event("newchat")); } } catch (error) { @@ -555,7 +681,16 @@ export function ChatHistoryList({ } } }, - [openai, currentChatId, archivedChats, localState, queryClient, router] + [ + archivedChats, + currentChatId, + invalidateConversationData, + localState, + opensecret, + queryClient, + router, + setSelectedProjectId + ] ); const MAX_SELECTION = 20; @@ -596,11 +731,11 @@ export function ChatHistoryList({ if (conversationIds.length > 0 && opensecret) { const result = await opensecret.batchDeleteConversations(conversationIds); - // Remove successfully deleted conversations from local state const deletedIds = new Set( result.data.filter((item) => item.deleted).map((item) => item.id) ); setConversations((prev) => prev.filter((conv) => !deletedIds.has(conv.id))); + await invalidateConversationData(); } // Delete archived chats individually @@ -636,6 +771,7 @@ export function ChatHistoryList({ selectedIds, archivedChats, opensecret, + invalidateConversationData, localState, queryClient, currentChatId, @@ -692,100 +828,289 @@ export function ChatHistoryList({ return () => window.removeEventListener("openbulkdelete", handleOpenBulkDelete); }, [selectedIds.size]); - const handleOpenRenameDialog = useCallback((conv: Conversation) => { - const title = conv.metadata?.title || "Untitled Chat"; - setSelectedChat({ id: conv.id, title }); - setIsRenameDialogOpen(true); + useEffect(() => { + const handleOpenBulkMove = () => { + if (selectedIds.size > 0) { + setIsMoveDialogOpen(true); + } + }; + window.addEventListener("openbulkmove", handleOpenBulkMove); + return () => window.removeEventListener("openbulkmove", handleOpenBulkMove); + }, [selectedIds.size]); + + const handleMoveConversationToProject = useCallback( + async (conversation: Conversation, projectId: string | null) => { + try { + await opensecret.batchUpdateConversationProject([conversation.id], projectId); + await invalidateConversationData(); + + if (conversation.id === currentChatId) { + setSelectedProjectId(projectId); + } + + dispatchConversationMetadataUpdated(conversation.id, { + projectId + }); + } catch (error) { + console.error("Error moving conversation to project:", error); + } + }, + [ + currentChatId, + dispatchConversationMetadataUpdated, + invalidateConversationData, + opensecret, + setSelectedProjectId + ] + ); + + const handleToggleConversationPin = useCallback( + async (conversation: Conversation) => { + try { + await opensecret.updateConversation(conversation.id, undefined, { + pinned: !conversation.pinned + }); + await invalidateConversationData(); + dispatchConversationMetadataUpdated(conversation.id, { + pinned: !conversation.pinned + }); + } catch (error) { + console.error("Error toggling conversation pin:", error); + } + }, + [dispatchConversationMetadataUpdated, invalidateConversationData, opensecret] + ); + + const handleMoveSelectedConversations = useCallback( + async (projectId: string | null) => { + if (selectedIds.size === 0) return; + + setIsBulkMoving(true); + try { + const ids = Array.from(selectedIds); + await opensecret.batchUpdateConversationProject(ids, projectId); + await invalidateConversationData(); + + if (currentChatId && selectedIds.has(currentChatId)) { + setSelectedProjectId(projectId); + } + + onSelectionChange(new Set()); + onExitSelectionMode?.(); + } catch (error) { + console.error("Error moving selected chats:", error); + throw error; + } finally { + setIsBulkMoving(false); + } + }, + [ + currentChatId, + invalidateConversationData, + onExitSelectionMode, + onSelectionChange, + opensecret, + selectedIds, + setSelectedProjectId + ] + ); + + const handleToggleProjectExpanded = useCallback((projectId: string) => { + setExpandedProjectId((currentProjectId) => (currentProjectId === projectId ? null : projectId)); }, []); + const handleViewProject = useCallback( + async (projectId: string) => { + setSelectedProjectId(projectId); + setExpandedProjectId(projectId); + + if (window.location.pathname !== "/") { + await router.navigate({ to: "/" }); + } + + const params = new URLSearchParams(window.location.search); + params.delete("conversation_id"); + params.set("project_id", projectId); + + window.history.replaceState({}, "", params.toString() ? `/?${params.toString()}` : "/"); + window.dispatchEvent(new CustomEvent("newchat", { detail: { projectId } })); + window.dispatchEvent(new Event("projectselected")); + }, + [router, setSelectedProjectId] + ); + + const handleCreateProject = useCallback( + async (name: string) => { + const project = await opensecret.createConversationProject({ name }); + await invalidateConversationData(); + await handleViewProject(project.id); + }, + [handleViewProject, invalidateConversationData, opensecret] + ); + + const handleRenameProject = useCallback( + async (name: string) => { + if (!selectedProject) return; + await opensecret.updateConversationProject(selectedProject.id, { name }); + await invalidateConversationData(); + }, + [invalidateConversationData, opensecret, selectedProject] + ); + + const handleDeleteProject = useCallback(async () => { + if (!selectedProject) return; + + try { + await opensecret.deleteConversationProject(selectedProject.id); + await invalidateConversationData(); + + if (expandedProjectId === selectedProject.id) { + setExpandedProjectId(null); + } + + if (selectedProjectId === selectedProject.id) { + setSelectedProjectId(null); + const params = new URLSearchParams(window.location.search); + params.delete("conversation_id"); + params.delete("project_id"); + window.history.replaceState({}, "", params.toString() ? `/?${params.toString()}` : "/"); + window.dispatchEvent(new CustomEvent("newchat", { detail: { projectId: null } })); + window.dispatchEvent(new Event("projectselected")); + } + } catch (error) { + console.error("Error deleting project:", error); + throw error; + } + }, [ + expandedProjectId, + invalidateConversationData, + opensecret, + selectedProject, + selectedProjectId, + setSelectedProjectId + ]); + + const handleOpenRenameDialog = useCallback( + (conv: Conversation) => { + setSelectedChat({ id: conv.id, title: getConversationTitle(conv) }); + setIsRenameDialogOpen(true); + }, + [getConversationTitle] + ); + const handleOpenRenameDialogArchived = useCallback((chat: ArchivedChat) => { setSelectedChat({ id: chat.id, title: chat.title }); setIsRenameDialogOpen(true); }, []); - const handleOpenDeleteDialog = useCallback((conv: Conversation) => { - const title = conv.metadata?.title || "Untitled Chat"; - setSelectedChat({ id: conv.id, title }); - setIsDeleteDialogOpen(true); - }, []); + const handleOpenDeleteDialog = useCallback( + (conv: Conversation) => { + setSelectedChat({ id: conv.id, title: getConversationTitle(conv) }); + setIsDeleteDialogOpen(true); + }, + [getConversationTitle] + ); const handleOpenDeleteDialogArchived = useCallback((chat: ArchivedChat) => { setSelectedChat({ id: chat.id, title: chat.title }); setIsDeleteDialogOpen(true); }, []); + const handleOpenCreateProjectDialog = useCallback(() => { + setSelectedProject(null); + setProjectDialogMode("create"); + setIsProjectDialogOpen(true); + }, []); + + const handleOpenRenameProjectDialog = useCallback((project: ConversationProjectListItem) => { + setSelectedProject(project); + setProjectDialogMode("rename"); + setIsProjectDialogOpen(true); + }, []); + + const handleOpenDeleteProjectDialog = useCallback((project: ConversationProjectListItem) => { + setSelectedProject(project); + setIsDeleteProjectDialogOpen(true); + }, []); + // Handle conversation renaming via API const handleRenameConversation = useCallback( async (conversationId: string, newTitle: string) => { - // Check if it's an archived chat const isArchived = archivedChats?.some((chat) => chat.id === conversationId); if (isArchived) { - // Handle archived chat rename if (!localState?.renameChat) return; try { await localState.renameChat(conversationId, newTitle); - // Refresh archived chats list - queryClient.invalidateQueries({ queryKey: ["archivedChats"] }); + await queryClient.invalidateQueries({ queryKey: ["archivedChats"] }); } catch (error) { console.error("Error renaming archived chat:", error); throw error; } } else { - // Handle API conversation rename - if (!openai) return; try { - // Update conversation metadata with new title - await openai.conversations.update(conversationId, { - metadata: { title: newTitle } - }); - - // Update local state immediately + await opensecret.updateConversation(conversationId, { title: newTitle }); setConversations((prev) => prev.map((conv) => conv.id === conversationId - ? { ...conv, metadata: { ...conv.metadata, title: newTitle } } + ? { ...conv, metadata: { ...(conv.metadata ?? {}), title: newTitle } } : conv ) ); + await invalidateConversationData(); + dispatchConversationMetadataUpdated(conversationId, { + metadata: { title: newTitle } + }); } catch (error) { console.error("Error renaming conversation:", error); throw error; } } }, - [openai, archivedChats, localState, queryClient] + [ + archivedChats, + dispatchConversationMetadataUpdated, + invalidateConversationData, + localState, + opensecret, + queryClient + ] ); // Handle conversation selection - const handleSelectConversation = useCallback((conversationId: string) => { - // Update URL with conversation ID - const params = new URLSearchParams(window.location.search); - params.set("conversation_id", conversationId); - const newUrl = `/?${params}`; - - // Update the URL - window.history.pushState({}, "", newUrl); - - // Always dispatch event for UnifiedChat to handle - // This ensures UnifiedChat knows about the change even if URL is the same - window.dispatchEvent( - new CustomEvent("conversationselected", { - detail: { conversationId } - }) - ); - }, []); + const handleSelectConversation = useCallback( + async (conversation: Conversation) => { + setSelectedProjectId(conversation.project_id ?? null); + + if (window.location.pathname !== "/") { + await router.navigate({ to: "/" }); + } + + const params = new URLSearchParams(window.location.search); + params.delete("project_id"); + params.set("conversation_id", conversation.id); + window.history.pushState({}, "", `/?${params.toString()}`); + + window.dispatchEvent( + new CustomEvent("conversationselected", { + detail: { conversationId: conversation.id } + }) + ); + }, + [router, setSelectedProjectId] + ); // Listen for conversation created event to refresh the list useEffect(() => { const handleConversationCreated = () => { - // Trigger immediate poll to get the new conversation pollForUpdates(); + queryClient.invalidateQueries({ queryKey: ["projectConversations"] }); + queryClient.invalidateQueries({ queryKey: ["pinnedConversations"] }); }; window.addEventListener("conversationcreated", handleConversationCreated); return () => window.removeEventListener("conversationcreated", handleConversationCreated); - }, [pollForUpdates]); + }, [pollForUpdates, queryClient]); if (error) { return
{error.message}
; @@ -795,9 +1120,15 @@ export function ChatHistoryList({ return
Loading chat history...
; } - // Only show no results message if we have a trimmed search query and no results anywhere const trimmedQuery = searchQuery.trim(); - if (trimmedQuery && filteredConversations.length === 0 && filteredArchivedChats.length === 0) { + if ( + trimmedQuery && + filteredProjects.length === 0 && + filteredExpandedProjectConversations.length === 0 && + filteredPinnedConversations.length === 0 && + filteredRecentConversations.length === 0 && + filteredArchivedChats.length === 0 + ) { return (

No chats found matching "{trimmedQuery}"

@@ -806,6 +1137,127 @@ export function ChatHistoryList({ ); } + const renderConversationRow = (conversation: Conversation, options?: { compact?: boolean }) => { + const title = getConversationTitle(conversation); + const isActive = conversation.id === currentChatId; + const isSelected = selectedIds.has(conversation.id); + + return ( +
event.preventDefault()} + > +
{ + if (isSelectionMode) { + toggleSelection(conversation.id); + } else { + void handleSelectConversation(conversation); + } + }} + onMouseDown={() => isMobile && handleLongPressStart(conversation.id)} + onMouseUp={handleLongPressEnd} + onMouseLeave={handleLongPressEnd} + onTouchStart={() => isMobile && handleLongPressStart(conversation.id)} + onTouchMove={handleLongPressMove} + onTouchEnd={handleLongPressEnd} + onTouchCancel={handleLongPressEnd} + className={`cursor-pointer rounded-lg py-2 transition-all hover:text-primary ${ + isActive && !isSelectionMode ? "text-primary" : "text-muted-foreground" + } ${options?.compact ? "pl-4" : ""} ${isSelectionMode ? "pl-8" : ""}`} + > + {isSelectionMode ? ( +
+ toggleSelection(conversation.id)} + onClick={(event) => event.stopPropagation()} + className="data-[state=checked]:bg-primary" + /> +
+ ) : null} +
+
+ {title} +
+
+ {new Date(conversation.last_activity_at * 1000).toLocaleDateString()} +
+
+
+ {!isSelectionMode ? ( + <> + + + + + + onSelectionChange(new Set([conversation.id]))}> + + Select + + handleToggleConversationPin(conversation)}> + {conversation.pinned ? ( + + ) : ( + + )} + {conversation.pinned ? "Unpin Chat" : "Pin Chat"} + + + + + Move to Project + + + handleMoveConversationToProject(conversation, null)} + > + + No project + + + {conversationProjects.map((project) => ( + handleMoveConversationToProject(conversation, project.id)} + > + + {project.name} + + ))} + + + handleOpenRenameDialog(conversation)}> + + Rename Chat + + handleOpenDeleteDialog(conversation)}> + + Delete Chat + + + +
+ + ) : null} +
+ ); + }; + return ( <>
-
- {filteredConversations.map((conv: Conversation, index: number) => { - const title = conv.metadata?.title || "Untitled Chat"; - const isActive = conv.id === currentChatId; - const isSelected = selectedIds.has(conv.id); - const isLastConversation = index === filteredConversations.length - 1; - // Only attach ref when not searching and it's the last item - const shouldAttachRef = isLastConversation && !searchQuery.trim(); +
+
+
Projects
+ + + {filteredProjects.map((project) => { + const isProjectExpanded = expandedProjectId === project.id; + const isProjectSelected = selectedProjectId === project.id; + return ( +
+
+ + + + + + + void handleViewProject(project.id)}> + + View Project + + + handleOpenRenameProjectDialog(project)}> + + Rename Project + + handleOpenDeleteProjectDialog(project)}> + + Delete Project + + + +
- return ( -
e.preventDefault()} - > -
{ - if (isSelectionMode) { - toggleSelection(conv.id); - } else { - handleSelectConversation(conv.id); - } - }} - onMouseDown={() => isMobile && handleLongPressStart(conv.id)} - onMouseUp={handleLongPressEnd} - onMouseLeave={handleLongPressEnd} - onTouchStart={() => isMobile && handleLongPressStart(conv.id)} - onTouchMove={handleLongPressMove} - onTouchEnd={handleLongPressEnd} - onTouchCancel={handleLongPressEnd} - className={`rounded-lg py-2 transition-all hover:text-primary cursor-pointer ${ - isActive && !isSelectionMode ? "text-primary" : "text-muted-foreground" - } ${isSelectionMode ? "pl-8" : ""}`} - > - {isSelectionMode && ( -
- toggleSelection(conv.id)} - onClick={(e) => e.stopPropagation()} - className="data-[state=checked]:bg-primary" - /> + {isProjectExpanded && filteredExpandedProjectConversations.length > 0 ? ( +
+ {filteredExpandedProjectConversations.map((conversation) => + renderConversationRow(conversation, { compact: true }) + )}
- )} -
- {title} -
-
- {new Date(conv.created_at * 1000).toLocaleDateString()} -
+ ) : null}
- {!isSelectionMode && ( - - - - - - onSelectionChange(new Set([conv.id]))}> - - Select - - handleOpenRenameDialog(conv)}> - - Rename Chat - - handleOpenDeleteDialog(conv)}> - - Delete Chat - - - - )} - {!isSelectionMode && ( -
- )} + ); + })} +
+ + {filteredPinnedConversations.length > 0 ? ( +
+
+ + Pinned
- ); - })} + {filteredPinnedConversations.map((conversation) => renderConversationRow(conversation))} +
+ ) : null} + +
+
Recents
+ {filteredRecentConversations.length > 0 ? ( + filteredRecentConversations.map((conversation) => renderConversationRow(conversation)) + ) : ( +
No recent chats.
+ )} +
- {/* Loading indicator for pagination */} {isLoadingMore && (
@@ -928,12 +1383,11 @@ export function ChatHistoryList({
)} - {/* Archived Chats Section - only show if there are archived chats */} {filteredArchivedChats && filteredArchivedChats.length > 0 && ( -
+
+ + + + + + ); +} diff --git a/frontend/src/components/ConversationProjectPicker.tsx b/frontend/src/components/ConversationProjectPicker.tsx new file mode 100644 index 000000000..f8c14f4e3 --- /dev/null +++ b/frontend/src/components/ConversationProjectPicker.tsx @@ -0,0 +1,77 @@ +import { Check, Folder, FolderOpen } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { useOpenSecret } from "@opensecret/react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/utils/utils"; +import { listAllConversationProjects } from "@/utils/paginatedLists"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; + +interface ConversationProjectPickerProps { + selectedProjectId: string | null; + onSelect: (projectId: string | null) => void | Promise; + disabled?: boolean; +} + +export function ConversationProjectPicker({ + selectedProjectId, + onSelect, + disabled = false +}: ConversationProjectPickerProps) { + const os = useOpenSecret(); + const userId = os.auth.user?.user.id; + const { data: projects = [] } = useQuery({ + queryKey: ["conversationProjects", userId], + queryFn: () => listAllConversationProjects(os), + enabled: !!userId + }); + + const selectedProject = projects.find((project) => project.id === selectedProjectId) ?? null; + const isProjectSelected = !!selectedProject; + + return ( + + + + + + onSelect(null)}> + + + No project + + {projects.length > 0 ? : null} + {projects.map((project) => ( + onSelect(project.id)}> + + + {project.name} + + ))} + + + ); +} diff --git a/frontend/src/components/DeleteAccountDialog.tsx b/frontend/src/components/DeleteAccountDialog.tsx index 2bf1351c1..1d43ac233 100644 --- a/frontend/src/components/DeleteAccountDialog.tsx +++ b/frontend/src/components/DeleteAccountDialog.tsx @@ -12,6 +12,7 @@ import { AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; +import { useQueryClient } from "@tanstack/react-query"; import { generateSecureSecret, hashSecret, useOpenSecret } from "@opensecret/react"; import { getBillingService } from "@/billing/billingService"; import { Loader2 } from "lucide-react"; @@ -23,6 +24,7 @@ interface DeleteAccountDialogProps { export function DeleteAccountDialog({ open, onOpenChange }: DeleteAccountDialogProps) { const os = useOpenSecret(); + const queryClient = useQueryClient(); const [step, setStep] = useState<"request" | "confirm">("request"); const [uuid, setUuid] = useState(""); const [secret, setSecret] = useState(""); @@ -88,6 +90,7 @@ export function DeleteAccountDialog({ open, onOpenChange }: DeleteAccountDialogP // Sign out await os.signOut(); + queryClient.clear(); // Force page refresh to go to logged out state window.location.href = "/"; diff --git a/frontend/src/components/DeleteConversationProjectDialog.tsx b/frontend/src/components/DeleteConversationProjectDialog.tsx new file mode 100644 index 000000000..4ab254a9a --- /dev/null +++ b/frontend/src/components/DeleteConversationProjectDialog.tsx @@ -0,0 +1,112 @@ +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from "@/components/ui/alert-dialog"; +import { useEffect, useState } from "react"; +import { AlertDestructive } from "@/components/AlertDestructive"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +interface DeleteConversationProjectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => Promise | void; + projectName: string; +} + +export function DeleteConversationProjectDialog({ + open, + onOpenChange, + onConfirm, + projectName +}: DeleteConversationProjectDialogProps) { + const [confirmText, setConfirmText] = useState(""); + const [error, setError] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + useEffect(() => { + if (open) { + setConfirmText(""); + setError(null); + setIsDeleting(false); + } + }, [open]); + + async function handleConfirm(event: React.FormEvent) { + event.preventDefault(); + if (confirmText !== "DELETE") { + setError("Please type DELETE to confirm."); + return; + } + + setIsDeleting(true); + setError(null); + + try { + await onConfirm(); + onOpenChange(false); + } catch (error) { + console.error("Failed to delete project:", error); + setError("Failed to delete project. Please try again."); + } finally { + setIsDeleting(false); + } + } + + return ( + + + + Delete this project? + + {`This will permanently delete "${projectName}" and any chats in it. Move chats out first if you want to keep them. This action cannot be undone.`} + + + +
+
+ Type `DELETE` to confirm deleting this project and all chats in it. +
+ +
+ + { + setConfirmText(event.target.value); + if (error) { + setError(null); + } + }} + placeholder="DELETE" + disabled={isDeleting} + autoCapitalize="characters" + autoCorrect="off" + spellCheck={false} + /> +
+ + {error ? : null} + + + Cancel + + + +
+
+ ); +} diff --git a/frontend/src/components/GuestPaymentWarningDialog.tsx b/frontend/src/components/GuestPaymentWarningDialog.tsx index ac65ff631..c0d805551 100644 --- a/frontend/src/components/GuestPaymentWarningDialog.tsx +++ b/frontend/src/components/GuestPaymentWarningDialog.tsx @@ -6,6 +6,7 @@ import { DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { useOpenSecret } from "@opensecret/react"; import { AlertTriangle, LogOut, CreditCard } from "lucide-react"; @@ -18,6 +19,7 @@ interface GuestPaymentWarningDialogProps { export function GuestPaymentWarningDialog({ open, onOpenChange }: GuestPaymentWarningDialogProps) { const navigate = useNavigate(); const os = useOpenSecret(); + const queryClient = useQueryClient(); const handleGoToPricing = () => { navigate({ to: "/pricing" }); @@ -33,6 +35,7 @@ export function GuestPaymentWarningDialog({ open, onOpenChange }: GuestPaymentWa } await os.signOut(); + queryClient.clear(); }; return ( diff --git a/frontend/src/components/MoveChatsDialog.tsx b/frontend/src/components/MoveChatsDialog.tsx new file mode 100644 index 000000000..41c70f7bf --- /dev/null +++ b/frontend/src/components/MoveChatsDialog.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react"; +import { Check, Folder, FolderOpen } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import type { ConversationProjectListItem } from "@opensecret/react"; +import { cn } from "@/utils/utils"; + +interface MoveChatsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + count: number; + projects: ConversationProjectListItem[]; + onConfirm: (projectId: string | null) => Promise; + isMoving?: boolean; +} + +export function MoveChatsDialog({ + open, + onOpenChange, + count, + projects, + onConfirm, + isMoving = false +}: MoveChatsDialogProps) { + const [selectedProjectId, setSelectedProjectId] = useState(undefined); + const [error, setError] = useState(null); + + useEffect(() => { + setSelectedProjectId(undefined); + setError(null); + }, [open]); + + async function handleConfirm() { + if (selectedProjectId === undefined) { + return; + } + + setError(null); + + try { + await onConfirm(selectedProjectId); + onOpenChange(false); + } catch (error) { + console.error("Failed to move chats:", error); + setError("Failed to move selected chats. Please try again."); + } + } + + function renderOption( + key: string, + label: string, + value: string | null, + icon: React.ReactNode, + description?: string + ) { + const isSelected = selectedProjectId === value; + + return ( + + ); + } + + return ( + + { + if (isMoving) { + event.preventDefault(); + } + }} + onEscapeKeyDown={(event) => { + if (isMoving) { + event.preventDefault(); + } + }} + > + + + Move {count} chat{count === 1 ? "" : "s"} + + Choose where the selected chats should live. + +
+ {error ? ( + + {error} + + ) : null} + {renderOption("no-project", "No project", null, )} + {projects.map((project) => + renderOption(project.id, project.name, project.id, ) + )} +
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/ProjectDetailView.tsx b/frontend/src/components/ProjectDetailView.tsx new file mode 100644 index 000000000..12e7f831a --- /dev/null +++ b/frontend/src/components/ProjectDetailView.tsx @@ -0,0 +1,848 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + CheckSquare, + Folder, + FolderInput, + Loader2, + MessageSquare, + MoreHorizontal, + Pencil, + Pin, + PinOff, + SquarePen, + Trash2 +} from "lucide-react"; +import { useOpenSecret, type Conversation } from "@opensecret/react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Sidebar, SidebarToggle } from "@/components/Sidebar"; +import { useIsMobile } from "@/utils/utils"; +import { useLocalState } from "@/state/useLocalState"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import { ConversationProjectDialog } from "@/components/ConversationProjectDialog"; +import { DeleteConversationProjectDialog } from "@/components/DeleteConversationProjectDialog"; +import { RenameChatDialog } from "@/components/RenameChatDialog"; +import { DeleteChatDialog } from "@/components/DeleteChatDialog"; +import { BulkDeleteDialog } from "@/components/BulkDeleteDialog"; +import { MoveChatsDialog } from "@/components/MoveChatsDialog"; +import { listAllConversationProjects } from "@/utils/paginatedLists"; + +const PROJECT_PAGE_SIZE = 20; +const MAX_SELECTION = 20; + +interface ProjectDetailViewProps { + projectId: string; +} + +function getConversationTitle(conversation: Conversation) { + const title = conversation.metadata?.title; + return typeof title === "string" && title.trim() ? title : "Untitled Chat"; +} + +function ProjectInstructionsDialog({ + open, + onOpenChange, + projectName, + initialInstructions, + onSave +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + projectName: string; + initialInstructions: string | null; + onSave: (instructions: string | null) => Promise; +}) { + const [instructions, setInstructions] = useState(initialInstructions ?? ""); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (open) { + setInstructions(initialInstructions ?? ""); + setError(null); + } + }, [initialInstructions, open]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setIsSaving(true); + + try { + await onSave(instructions.trim() ? instructions : null); + onOpenChange(false); + } catch (saveError) { + console.error("Failed to save project instructions:", saveError); + setError("Failed to save project instructions. Please try again."); + } finally { + setIsSaving(false); + } + } + + return ( + + + + Custom Instructions + These instructions apply to chats in {projectName}. + +
+ {error ? ( + + {error} + + ) : null} +
+ +