"use client"; import { Archive, ChevronRight, Folder, FolderTree, List, Plus, Trash2, } from "lucide-react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; import { useI18n } from "@/core/i18n/hooks"; import { useCreateProject, useProjects, type Project } from "@/core/projects"; import { useLocalSettings } from "@/core/settings"; import { isStaticWebsiteOnly } from "@/core/static-mode"; import { useInfiniteThreads } from "@/core/threads/hooks"; import { flattenThreadBranches } from "@/core/threads/thread-branch-tree"; import { buildThreadListModel } from "@/core/threads/thread-list-model"; import type { AgentThread } from "@/core/threads/types"; import { pathOfThread, projectIdOfThread } from "@/core/threads/utils"; import { env } from "@/env"; import { isIMEComposing } from "@/lib/ime"; import { ThreadSidebarItem } from "./recent-chat-list"; function projectPath(projectId: string): string { return `/workspace/projects/${projectId}`; } function ProjectThreadGroup({ project, threads, recentThreadId, }: { project: Project; threads: readonly AgentThread[]; /** Global most-recent thread id — mirrors flat mode's `threads[0]?.thread_id`. */ recentThreadId: string | undefined; }) { const pathname = usePathname(); const [open, setOpen] = useState(true); const href = projectPath(project.id); const branchEntries = useMemo( () => flattenThreadBranches([...threads]), [threads], ); return ( {project.name} {branchEntries.map((entry) => ( ))} ); } function ArchivedProjectsGroup({ projects, threadsByProject, recentThreadId, }: { projects: readonly Project[]; threadsByProject: ReadonlyMap; recentThreadId: string | undefined; }) { const { t } = useI18n(); const [open, setOpen] = useState(false); return ( {t.projects.archived} {projects.map((project) => ( ))} ); } /** * Grouped mode: active projects as collapsible headers with their recent * threads (grouped client-side from the already-fetched infinite thread * pages), plus a collapsed "Archived" section at the bottom. */ function GroupedProjectList() { const { data: activeProjects } = useProjects("active"); const { data: archivedProjects } = useProjects("archived"); const { data: infiniteThreads } = useInfiniteThreads({ archived: env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? undefined : false, }); const { thread_id: threadIdFromPath } = useParams<{ thread_id: string; agent_name?: string; }>(); const threadListModel = useMemo( () => buildThreadListModel(infiniteThreads?.pages ?? []), [infiniteThreads?.pages], ); // Same value flat mode passes to `ThreadSidebarItem` — the global most-recent // thread, NOT the capped displayedThreads or any group-local first entry. const globalRecentThreadId = threadListModel.threads[0]?.thread_id; // Mirror `RecentChatList`'s active-thread exception: the path-active thread // is appended even when it falls beyond the unpinned display cap, so it // must join the partition input too — otherwise an active assigned chat // renders in neither the flat list nor its project group. const partitionableThreads = useMemo(() => { if ( !threadIdFromPath || threadListModel.displayedThreads.some( (thread) => thread.thread_id === threadIdFromPath, ) ) { return threadListModel.displayedThreads; } const activeThread = threadListModel.byId.get(threadIdFromPath); return activeThread ? [...threadListModel.displayedThreads, activeThread] : threadListModel.displayedThreads; }, [threadIdFromPath, threadListModel]); const threadsByProject = useMemo(() => { const grouped = new Map(); for (const thread of partitionableThreads) { const projectId = projectIdOfThread(thread); if (projectId === null) { continue; } const projectThreads = grouped.get(projectId); if (projectThreads) { projectThreads.push(thread); } else { grouped.set(projectId, [thread]); } } return grouped; }, [partitionableThreads]); if ( (!activeProjects || activeProjects.length === 0) && (!archivedProjects || archivedProjects.length === 0) ) { return null; } return ( {activeProjects?.map((project) => ( ))} {archivedProjects && archivedProjects.length > 0 && ( )} ); } export function ProjectsSection() { const { t } = useI18n(); const [settings, setSettings] = useLocalSettings(); const groupByProject = settings.projectsDisplayMode === "grouped"; const { mutate: createProject, isPending: isCreating } = useCreateProject(); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createName, setCreateName] = useState(""); const handleCreateSubmit = useCallback(() => { const name = createName.trim(); if (!name || isCreating) { return; } createProject( { name }, { onSuccess: () => { setCreateDialogOpen(false); setCreateName(""); }, onError: (error) => { toast.error( error instanceof Error && error.message ? error.message : t.projects.createFailed, ); }, }, ); }, [createProject, createName, isCreating, t.projects.createFailed]); // Static-demo mode has no Gateway and no projects: hide the whole section // (New project button, grouped toggle, and groups are all dead actions). if (isStaticWebsiteOnly()) { return null; } return ( {t.projects.title} {groupByProject && ( )} {/* New project dialog */} {t.projects.newProject}
setCreateName(e.target.value)} placeholder={t.projects.namePlaceholder} onKeyDown={(e) => { if (e.key === "Enter" && !isIMEComposing(e)) { e.preventDefault(); handleCreateSubmit(); } }} />
); }