Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions frontend/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -96,6 +97,7 @@ export default function App() {
}}
>
<LocalStateProvider>
<ProjectsProvider>
<OpenAIProvider>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
Expand All @@ -110,6 +112,7 @@ export default function App() {
</TooltipProvider>
</QueryClientProvider>
</OpenAIProvider>
</ProjectsProvider>
</LocalStateProvider>
</OpenSecretProvider>
</NotificationProvider>
Expand Down
166 changes: 166 additions & 0 deletions frontend/src/components/ChatContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DropdownMenu onOpenChange={(open) => !open && setShowProjectSubmenu(false)}>
<DropdownMenuTrigger asChild>
<button
className={`z-50 bg-background/80 absolute right-2 top-1/2 transform -translate-y-1/2 text-primary transition-opacity p-2 ${
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<MoreHorizontal size={16} />
</button>
Comment on lines +55 to +65
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Trigger button lacks an accessible label.

The <button> wrapping <MoreHorizontal /> has no aria-label, so screen readers will announce it as an unlabeled button. Add an aria-label for accessibility.

♻️ Suggested fix
         <button
           className={`z-50 bg-background/80 absolute right-2 top-1/2 transform -translate-y-1/2 text-primary transition-opacity p-2 ${
             isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
           }`}
+          aria-label="Chat actions"
           onClick={(e) => {
             e.preventDefault();
             e.stopPropagation();
           }}
         >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
className={`z-50 bg-background/80 absolute right-2 top-1/2 transform -translate-y-1/2 text-primary transition-opacity p-2 ${
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<MoreHorizontal size={16} />
</button>
<button
className={`z-50 bg-background/80 absolute right-2 top-1/2 transform -translate-y-1/2 text-primary transition-opacity p-2 ${
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`}
aria-label="Chat actions"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<MoreHorizontal size={16} />
</button>
🤖 Prompt for AI Agents
In `@frontend/src/components/ChatContextMenu.tsx` around lines 53 - 63, The button
in ChatContextMenu wrapping the MoreHorizontal icon lacks an accessible label,
so update the <button> element inside the ChatContextMenu component to include
an appropriate aria-label (e.g., "Open message menu" or similar) so screen
readers can announce its purpose; locate the button using the MoreHorizontal
icon and isMobile usage and add the aria-label attribute and ensure it remains
descriptive and concise.

</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 overflow-hidden">
<div className="relative overflow-hidden">
{/* Main menu layer — in-flow when active, absolute when hidden */}
<div
className={`transition-transform duration-300 ease-in-out ${
showProjectSubmenu
? "absolute top-0 left-0 w-full -translate-x-[200%]"
: "translate-x-0"
}`}
>
{onSelect && (
<DropdownMenuItem onClick={onSelect}>
<CheckSquare className="mr-2 h-4 w-4" />
<span>Select</span>
</DropdownMenuItem>
)}
{onRename && (
<DropdownMenuItem onClick={onRename}>
<Pencil className="mr-2 h-4 w-4" />
<span>Rename chat</span>
</DropdownMenuItem>
)}
{onMoveToProject && (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowProjectSubmenu(true);
}}
onSelect={(e) => e.preventDefault()}
>
<FolderPlus className="mr-2 h-4 w-4" />
<span className="flex-1">Move to project</span>
<ChevronRight className="h-4 w-4 ml-auto" />
</DropdownMenuItem>
)}
{onRemoveFromProject && currentProjectName && (
<DropdownMenuItem onClick={onRemoveFromProject}>
<FolderMinus className="mr-2 h-4 w-4" />
<span>Remove from {currentProjectName}</span>
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete chat</span>
</DropdownMenuItem>
)}
</div>

{/* Project submenu layer — in-flow when active, absolute when hidden */}
{onMoveToProject && (
<div
className={`transition-transform duration-300 ease-in-out ${
showProjectSubmenu
? "translate-x-0"
: "absolute top-0 left-0 w-full translate-x-[200%]"
}`}
>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowProjectSubmenu(false);
}}
onSelect={(e) => e.preventDefault()}
>
<ChevronLeft className="mr-2 h-4 w-4" />
<span>Back</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
window.dispatchEvent(
new CustomEvent("createprojectforchat", {
detail: { chatId }
})
);
}}
>
<Plus className="mr-2 h-4 w-4" />
<span>New project</span>
</DropdownMenuItem>
{projects.length > 0 && <DropdownMenuSeparator />}
<div className="max-h-[40vh] overflow-y-auto">
{projects.map((project) => (
<DropdownMenuItem key={project.id} onClick={() => onMoveToProject(project.id)}>
<Folder className="mr-2 h-4 w-4" />
<span className="truncate">{project.name}</span>
</DropdownMenuItem>
))}
</div>
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
128 changes: 88 additions & 40 deletions frontend/src/components/ChatHistoryList.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
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,
DropdownMenuContent,
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";
Expand All @@ -33,6 +34,8 @@ interface ChatHistoryListProps {
selectedIds: Set<string>;
onSelectionChange: (ids: Set<string>) => void;
containerRef?: React.RefObject<HTMLElement>;
excludeChatIds?: Set<string>;
onConversationsLoaded?: (conversations: Conversation[]) => void;
}

interface Conversation {
Expand Down Expand Up @@ -60,7 +63,9 @@ export function ChatHistoryList({
onExitSelectionMode,
selectedIds,
onSelectionChange,
containerRef
containerRef,
excludeChatIds,
onConversationsLoaded
}: ChatHistoryListProps) {
const openai = useOpenAI();
const opensecret = useOpenSecret();
Expand All @@ -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<ReturnType<typeof setTimeout> | null>(null);

// Projects integration
const {
projects,
getProjectForChat,
assignChatToProject,
removeChatFromProject
} = useProjects();

// Pagination states
const [oldestConversationId, setOldestConversationId] = useState<string | undefined>();
const [hasMoreConversations, setHasMoreConversations] = useState(false);
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -824,7 +866,29 @@ export function ChatHistoryList({
</div>

<div ref={pullContentRef} className="flex flex-col gap-2" style={{ willChange: "transform" }}>
{filteredConversations.map((conv: Conversation, index: number) => {
{/* Recents header */}
{filteredConversations.length > 0 && (
<button
onClick={toggleRecentsExpanded}
className="group/header flex items-center gap-1 w-full text-sm text-muted-foreground hover:text-foreground transition-colors py-1 mt-3"
>
<span className="font-medium">Recents</span>
<span
className={
!(isRecentsExpanded || (searchQuery.trim() && filteredConversations.length > 0)) || isMobile
? ""
: "opacity-0 group-hover/header:opacity-100 transition-opacity"
}
>
{isRecentsExpanded || (searchQuery.trim() && filteredConversations.length > 0) ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</span>
</button>
)}
{(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);
Expand Down Expand Up @@ -880,35 +944,17 @@ export function ChatHistoryList({
</div>
</div>
{!isSelectionMode && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={`z-50 bg-background/80 absolute right-2 top-1/2 transform -translate-y-1/2 text-primary transition-opacity p-2 ${
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<MoreHorizontal size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => onSelectionChange(new Set([conv.id]))}>
<CheckSquare className="mr-2 h-4 w-4" />
<span>Select</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleOpenRenameDialog(conv)}>
<Pencil className="mr-2 h-4 w-4" />
<span>Rename Chat</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleOpenDeleteDialog(conv)}>
<Trash className="mr-2 h-4 w-4" />
<span>Delete Chat</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ChatContextMenu
chatId={conv.id}
isMobile={isMobile}
projects={projects}
currentProjectName={getProjectForChat(conv.id)?.name}
onSelect={() => onSelectionChange(new Set([conv.id]))}
onRename={() => handleOpenRenameDialog(conv)}
onDelete={() => handleOpenDeleteDialog(conv)}
onMoveToProject={(projectId) => assignChatToProject(conv.id, projectId)}
onRemoveFromProject={() => removeChatFromProject(conv.id)}
/>
)}
{!isSelectionMode && (
<div className="absolute inset-y-0 right-0 w-[3rem] bg-gradient-to-l from-background to-transparent pointer-events-none"></div>
Expand All @@ -918,7 +964,7 @@ export function ChatHistoryList({
})}

{/* Loading indicator for pagination */}
{isLoadingMore && (
{(isRecentsExpanded || (searchQuery.trim() && filteredConversations.length > 0)) && isLoadingMore && (
<div className="flex items-center justify-center py-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="w-2 h-2 bg-foreground/60 rounded-full animate-pulse" />
Expand Down Expand Up @@ -1027,3 +1073,5 @@ export function ChatHistoryList({
</>
);
}

export type { Conversation };
Loading
Loading