"use client"; import * as React from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { Archive, ArchiveRestore, CheckCircle2, CircleOff, FileText, FolderKanban, MessageSquare, MessageSquarePlus, Pencil, Plus, Search, Trash2, WandSparkles } from "lucide-react"; import { useApp, invalidateProjects, invalidateConversations } from "@/components/app/store"; import { api, useApi } from "@/lib/client/api"; import { errorMessage } from "@/lib/client/humanize"; import { useIsMobile, useLongPress } from "@/lib/client/hooks"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Segmented } from "@/components/ui/segmented"; import { EmptyState, Skeleton } from "@/components/ui/misc"; import { toast } from "@/components/ui/toast"; import { ConfirmDialog } from "@/components/common/confirm-dialog"; import type { ActionSheetItem } from "@/components/ui/sheet"; import type { PublicProject } from "@/lib/client/types"; import { formatRelative, cn } from "@/lib/utils"; import { WorkspacePageHeader, WorkspacePageBody } from "./page-header"; import { ProjectFormSheet } from "./project-form-sheet"; import { ProjectIcon } from "./project-icon"; import { RowMenu } from "./row-menu"; type Scope = "active" | "archived"; export function ProjectsView() { const router = useRouter(); const params = useSearchParams(); const { activeProjectId, setActiveProjectId } = useApp(); const { data, isLoading, error, mutate } = useApi<{ projects: PublicProject[] }>("/api/projects?archived=1"); const [scope, setScope] = React.useState("active"); const [q, setQ] = React.useState(""); const [form, setForm] = React.useState<{ open: boolean; project: PublicProject | null }>({ open: false, project: null }); const [deleting, setDeleting] = React.useState(null); // `/app/projects?new=1` (sidebar "+") opens the create sheet once. const wantsNew = params.get("new") === "1"; React.useEffect(() => { if (!wantsNew) return; // eslint-disable-next-line react-hooks/set-state-in-effect setForm({ open: true, project: null }); router.replace("/app/projects"); }, [wantsNew, router]); const all = React.useMemo(() => data?.projects ?? [], [data]); const counts = React.useMemo(() => ({ active: all.filter((p) => !p.archived).length, archived: all.filter((p) => p.archived).length }), [all]); const list = React.useMemo(() => { const s = q.trim().toLowerCase(); return all.filter((p) => (scope === "archived" ? p.archived : !p.archived)).filter((p) => (s ? [p.name, p.description ?? ""].some((x) => x.toLowerCase().includes(s)) : true)); }, [all, scope, q]); const patch = async (p: PublicProject, body: Record, ok: string) => { try { await api(`/api/projects/${p.id}`, { method: "PATCH", json: body }); await Promise.all([mutate(), invalidateProjects()]); toast.success(ok); } catch (e) { toast.error("Could not update project", errorMessage(e)); } }; const remove = async (p: PublicProject) => { try { await api(`/api/projects/${p.id}`, { method: "DELETE" }); if (activeProjectId === p.id) setActiveProjectId(null); await Promise.all([mutate(), invalidateProjects(), invalidateConversations()]); toast.success("Project deleted", "Its chats were kept."); } catch (e) { toast.error("Could not delete project", errorMessage(e)); throw e; } }; const newChat = (p: PublicProject) => { setActiveProjectId(p.id); router.push("/app/chat"); }; const itemsFor = (p: PublicProject): (ActionSheetItem | "separator")[] => [ { key: "open", label: "Open", icon: , onSelect: () => router.push(`/app/projects/${p.id}`) }, { key: "chat", label: "New chat in this project", icon: , onSelect: () => newChat(p), disabled: p.archived }, activeProjectId === p.id ? { key: "inactive", label: "Clear active project", icon: , onSelect: () => setActiveProjectId(null) } : { key: "active", label: "Set as active project", icon: , onSelect: () => setActiveProjectId(p.id), disabled: p.archived }, { key: "edit", label: "Edit", icon: , onSelect: () => setForm({ open: true, project: p }) }, "separator", { key: "archive", label: p.archived ? "Unarchive" : "Archive", icon: p.archived ? : , onSelect: () => void patch(p, { archived: !p.archived }, p.archived ? "Project restored" : "Project archived") }, { key: "delete", label: "Delete", icon: , destructive: true, onSelect: () => setDeleting(p) }, ]; return (
setForm({ open: true, project: null })}> New project New } /> {all.length > 0 ? (
{all.length > 4 ? (
setQ(e.target.value)} placeholder="Search projects…" className="h-10 pl-8 sm:h-9" aria-label="Search projects" />
) : null}
) : null}
0 && "mt-4")}> {isLoading && !data ? (
    {Array.from({ length: 6 }).map((_, i) => (
  • ))}
) : error ? ( mutate()}>Retry} /> ) : all.length === 0 ? ( } title="No projects yet" description="Group chats, files, prompts and instructions. New chats in a project start with its system instructions and preferred models." action={ } /> ) : list.length === 0 ? (
setForm((s) => ({ ...s, open: o }))} project={form.project} onSaved={(p) => { void mutate(); if (!form.project) router.push(`/app/projects/${p.id}`); }} /> !o && setDeleting(null)} destructive title={`Delete “${deleting?.name ?? ""}”?`} description={`Chats stay in your history (${deleting?.conversationCount ?? 0}); the project's files (${deleting?.fileCount ?? 0}) are deleted and its prompts (${deleting?.promptCount ?? 0}) are detached.`} confirmLabel="Delete project" onConfirm={() => (deleting ? remove(deleting) : Promise.resolve())} />
); } function ProjectCard({ project: p, active, items, onNewChat }: { project: PublicProject; active: boolean; items: (ActionSheetItem | "separator")[]; onNewChat: () => void }) { const isMobile = useIsMobile(); const [menu, setMenu] = React.useState(false); const press = useLongPress({ onLongPress: () => setMenu(true), disabled: !isMobile }); return (
  • {p.name} {active ? Active : null} {p.archived ? Archived : null}

    {p.description ?

    {p.description}

    :

    No description

    }
    {p.conversationCount} {p.fileCount} {p.promptCount} {formatRelative(p.updatedAt)}
    {!p.archived ? (
    ) : null}
  • ); }