"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, Settings2, StickyNote, Trash2, WandSparkles } from "lucide-react"; import { useApp, invalidateProjects, invalidateConversations } from "@/components/app/store"; import { api, useApi, ClientApiError } from "@/lib/client/api"; import { errorMessage } from "@/lib/client/humanize"; import { Button } from "@/components/ui/button"; 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 { ProjectDetail as Detail, PublicProject, PublicPrompt } from "@/lib/client/types"; import { formatTokens, formatUsd, formatRelative } from "@/lib/utils"; import { FileList } from "@/components/library/file-list"; import { UploadButton } from "@/components/library/upload-button"; import { PromptCard } from "@/components/prompts/prompt-card"; import { PromptEditorSheet } from "@/components/prompts/prompt-editor-sheet"; import { PromptUseSheet, insertPromptIntoChat } from "@/components/prompts/prompt-use-sheet"; import { variablesToAsk } from "@/lib/prompts/variables"; import { WorkspacePageHeader, WorkspacePageBody } from "./page-header"; import { ProjectFormSheet } from "./project-form-sheet"; import { ProjectIcon } from "./project-icon"; import { RowMenu } from "./row-menu"; import { ProjectConversations } from "./project-conversations"; import { ProjectSetup } from "./project-setup"; import { ProjectNotes } from "./project-notes"; type Tab = "chats" | "files" | "prompts" | "setup" | "notes"; const TABS: Tab[] = ["chats", "files", "prompts", "setup", "notes"]; export function ProjectDetail({ id }: { id: string }) { const router = useRouter(); const params = useSearchParams(); const { activeProjectId, setActiveProjectId } = useApp(); const { data, error, isLoading, mutate } = useApi(`/api/projects/${id}`); const { data: projData } = useApi<{ projects: PublicProject[] }>("/api/projects"); const initialTab = params.get("tab"); const [tab, setTab] = React.useState(TABS.includes(initialTab as Tab) ? (initialTab as Tab) : "chats"); const [editing, setEditing] = React.useState(false); const [deleting, setDeleting] = React.useState(false); const [promptEditor, setPromptEditor] = React.useState<{ open: boolean; prompt: PublicPrompt | null }>({ open: false, prompt: null }); const [using, setUsing] = React.useState(null); const [deletingPrompt, setDeletingPrompt] = React.useState(null); const changeTab = (t: Tab) => { setTab(t); const url = new URL(window.location.href); if (t === "chats") url.searchParams.delete("tab"); else url.searchParams.set("tab", t); window.history.replaceState(null, "", url.toString()); }; const project = data?.project; const isActive = project?.id === activeProjectId; const newChat = () => { if (!project) return; setActiveProjectId(project.id); router.push("/app/chat"); }; const patch = async (body: Record, ok: string) => { try { await api(`/api/projects/${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 () => { try { await api(`/api/projects/${id}`, { method: "DELETE" }); if (isActive) setActiveProjectId(null); await Promise.all([invalidateProjects(), invalidateConversations()]); toast.success("Project deleted", "Its chats were kept."); router.replace("/app/projects"); } catch (e) { toast.error("Could not delete project", errorMessage(e)); throw e; } }; const runPrompt = (p: PublicPrompt) => { if (variablesToAsk(p.variables).length) setUsing(p); else void insertPromptIntoChat(p, {}, router).catch((e) => toast.error("Could not use prompt", errorMessage(e))); }; const promptActions = { onUse: runPrompt, onEdit: (p: PublicPrompt) => setPromptEditor({ open: true, prompt: p }), onToggleFavorite: async (p: PublicPrompt) => { await api(`/api/prompts/${p.id}`, { method: "PATCH", json: { favorite: !p.favorite } }); await mutate(); }, onDelete: (p: PublicPrompt) => setDeletingPrompt(p), }; const menuItems: (ActionSheetItem | "separator")[] = project ? [ { key: "edit", label: "Edit name, icon, colour", icon: , onSelect: () => setEditing(true) }, isActive ? { key: "inactive", label: "Clear active project", icon: , onSelect: () => setActiveProjectId(null) } : { key: "active", label: "Set as active project", icon: , onSelect: () => setActiveProjectId(project.id), disabled: project.archived }, "separator", { key: "archive", label: project.archived ? "Unarchive" : "Archive", icon: project.archived ? : , onSelect: () => void patch({ archived: !project.archived }, project.archived ? "Project restored" : "Project archived") }, { key: "delete", label: "Delete project", icon: , destructive: true, onSelect: () => setDeleting(true) }, ] : []; if (error) { const notFound = error instanceof ClientApiError && error.status === 404; return (
} title={notFound ? "Project not found" : "Could not load project"} description={notFound ? "It may have been deleted." : errorMessage(error)} action={ } />
); } return (
: } title={project ? project.name : } subtitle={project ? (isActive ? "Active project · new chats start here" : project.archived ? "Archived" : `${data.stats.conversations} chats · ${data.stats.files} files`) : undefined} actions={ project ? ( <> ) : null } /> {isLoading && !data ? (
) : project && data ? ( <> {project.description || data.stats.conversations || data.stats.files ? (
{project.description ?

{project.description}

: }
Chats
{data.stats.conversations}
Files
{data.stats.files}
Context tokens
~{formatTokens(data.stats.fileTokens)} tok
Cost
{formatUsd(data.stats.totalCostUsd)}
) : null} , count: data.stats.conversations }, { value: "files", label: "Files", icon: , count: data.stats.files }, { value: "prompts", label: "Prompts", icon: , count: data.stats.prompts }, { value: "setup", label: "Instructions & models", icon: }, { value: "notes", label: "Notes", icon: }, ]} />
{tab === "chats" ? mutate()} onNewChat={newChat} /> : null} {tab === "files" ? (

{data.files.length} file{data.files.length === 1 ? "" : "s"} {data.stats.fileTokens ? ` · ~${formatTokens(data.stats.fileTokens)} tokens` : ""}

mutate()} />
mutate()} contextProjectId={project.id} emptyTitle="No files in this project" emptyDescription="Upload PDFs, images, code or data. Attach them to any chat from the composer's “From library” option." emptyAction={ mutate()} />} />
) : null} {tab === "prompts" ? (

{data.prompts.length} prompt{data.prompts.length === 1 ? "" : "s"}

{data.prompts.length === 0 ? ( } title="No prompts in this project" description="Save the prompts you reuse for this work — with {{variables}} to fill in before sending." action={ } /> ) : (
    {data.prompts.map((p) => ( ))}
)}
) : null} {tab === "setup" ? mutate()} /> : null} {tab === "notes" ? mutate()} /> : null}
{data.stats.lastActivityAt ?

Last activity {formatRelative(data.stats.lastActivityAt)} · created {formatRelative(project.createdAt)}

: null} ) : null} mutate()} /> setPromptEditor((s) => ({ ...s, open: o }))} prompt={promptEditor.prompt} initial={{ projectId: id }} onSaved={() => mutate()} /> !o && setUsing(null)} /> !o && setDeletingPrompt(null)} destructive title={`Delete “${deletingPrompt?.name ?? ""}”?`} confirmLabel="Delete" onConfirm={async () => { if (!deletingPrompt) return; await api(`/api/prompts/${deletingPrompt.id}`, { method: "DELETE" }); await mutate(); toast.success("Prompt deleted"); }} />
); }