TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import Link from "next/link";4import { useRouter, useSearchParams } from "next/navigation";5import { Archive, ArchiveRestore, CheckCircle2, CircleOff, FileText, FolderKanban, MessageSquare, MessageSquarePlus, Pencil, Plus, Settings2, StickyNote, Trash2, WandSparkles } from "lucide-react";6import { useApp, invalidateProjects, invalidateConversations } from "@/components/app/store";7import { api, useApi, ClientApiError } from "@/lib/client/api";8import { errorMessage } from "@/lib/client/humanize";9import { Button } from "@/components/ui/button";10import { Segmented } from "@/components/ui/segmented";11import { EmptyState, Skeleton } from "@/components/ui/misc";12import { toast } from "@/components/ui/toast";13import { ConfirmDialog } from "@/components/common/confirm-dialog";14import type { ActionSheetItem } from "@/components/ui/sheet";15import type { ProjectDetail as Detail, PublicProject, PublicPrompt } from "@/lib/client/types";16import { formatTokens, formatUsd, formatRelative } from "@/lib/utils";17import { FileList } from "@/components/library/file-list";18import { UploadButton } from "@/components/library/upload-button";19import { PromptCard } from "@/components/prompts/prompt-card";20import { PromptEditorSheet } from "@/components/prompts/prompt-editor-sheet";21import { PromptUseSheet, insertPromptIntoChat } from "@/components/prompts/prompt-use-sheet";22import { variablesToAsk } from "@/lib/prompts/variables";23import { WorkspacePageHeader, WorkspacePageBody } from "./page-header";24import { ProjectFormSheet } from "./project-form-sheet";25import { ProjectIcon } from "./project-icon";26import { RowMenu } from "./row-menu";27import { ProjectConversations } from "./project-conversations";28import { ProjectSetup } from "./project-setup";29import { ProjectNotes } from "./project-notes";3031type Tab = "chats" | "files" | "prompts" | "setup" | "notes";32const TABS: Tab[] = ["chats", "files", "prompts", "setup", "notes"];3334export function ProjectDetail({ id }: { id: string }) {35 const router = useRouter();36 const params = useSearchParams();37 const { activeProjectId, setActiveProjectId } = useApp();38 const { data, error, isLoading, mutate } = useApi<Detail>(`/api/projects/${id}`);39 const { data: projData } = useApi<{ projects: PublicProject[] }>("/api/projects");40 const initialTab = params.get("tab");41 const [tab, setTab] = React.useState<Tab>(TABS.includes(initialTab as Tab) ? (initialTab as Tab) : "chats");42 const [editing, setEditing] = React.useState(false);43 const [deleting, setDeleting] = React.useState(false);44 const [promptEditor, setPromptEditor] = React.useState<{ open: boolean; prompt: PublicPrompt | null }>({ open: false, prompt: null });45 const [using, setUsing] = React.useState<PublicPrompt | null>(null);46 const [deletingPrompt, setDeletingPrompt] = React.useState<PublicPrompt | null>(null);4748 const changeTab = (t: Tab) => {49 setTab(t);50 const url = new URL(window.location.href);51 if (t === "chats") url.searchParams.delete("tab");52 else url.searchParams.set("tab", t);53 window.history.replaceState(null, "", url.toString());54 };5556 const project = data?.project;57 const isActive = project?.id === activeProjectId;5859 const newChat = () => {60 if (!project) return;61 setActiveProjectId(project.id);62 router.push("/app/chat");63 };64 const patch = async (body: Record<string, unknown>, ok: string) => {65 try {66 await api(`/api/projects/${id}`, { method: "PATCH", json: body });67 await Promise.all([mutate(), invalidateProjects()]);68 toast.success(ok);69 } catch (e) {70 toast.error("Could not update project", errorMessage(e));71 }72 };73 const remove = async () => {74 try {75 await api(`/api/projects/${id}`, { method: "DELETE" });76 if (isActive) setActiveProjectId(null);77 await Promise.all([invalidateProjects(), invalidateConversations()]);78 toast.success("Project deleted", "Its chats were kept.");79 router.replace("/app/projects");80 } catch (e) {81 toast.error("Could not delete project", errorMessage(e));82 throw e;83 }84 };8586 const runPrompt = (p: PublicPrompt) => {87 if (variablesToAsk(p.variables).length) setUsing(p);88 else void insertPromptIntoChat(p, {}, router).catch((e) => toast.error("Could not use prompt", errorMessage(e)));89 };90 const promptActions = {91 onUse: runPrompt,92 onEdit: (p: PublicPrompt) => setPromptEditor({ open: true, prompt: p }),93 onToggleFavorite: async (p: PublicPrompt) => {94 await api(`/api/prompts/${p.id}`, { method: "PATCH", json: { favorite: !p.favorite } });95 await mutate();96 },97 onDelete: (p: PublicPrompt) => setDeletingPrompt(p),98 };99100 const menuItems: (ActionSheetItem | "separator")[] = project101 ? [102 { key: "edit", label: "Edit name, icon, colour", icon: <Pencil />, onSelect: () => setEditing(true) },103 isActive ? { key: "inactive", label: "Clear active project", icon: <CircleOff />, onSelect: () => setActiveProjectId(null) } : { key: "active", label: "Set as active project", icon: <CheckCircle2 />, onSelect: () => setActiveProjectId(project.id), disabled: project.archived },104 "separator",105 { key: "archive", label: project.archived ? "Unarchive" : "Archive", icon: project.archived ? <ArchiveRestore /> : <Archive />, onSelect: () => void patch({ archived: !project.archived }, project.archived ? "Project restored" : "Project archived") },106 { key: "delete", label: "Delete project", icon: <Trash2 />, destructive: true, onSelect: () => setDeleting(true) },107 ]108 : [];109110 if (error) {111 const notFound = error instanceof ClientApiError && error.status === 404;112 return (113 <div className="flex h-full min-h-0 flex-col">114 <WorkspacePageHeader title="Project" back="/app/projects" />115 <WorkspacePageBody>116 <EmptyState117 icon={<FolderKanban />}118 title={notFound ? "Project not found" : "Could not load project"}119 description={notFound ? "It may have been deleted." : errorMessage(error)}120 action={121 <Button asChild variant="outline">122 <Link href="/app/projects">All projects</Link>123 </Button>124 }125 />126 </WorkspacePageBody>127 </div>128 );129 }130131 return (132 <div className="flex h-full min-h-0 flex-col">133 <WorkspacePageHeader134 back="/app/projects"135 leading={project ? <ProjectIcon icon={project.icon} color={project.color} size="sm" className="size-7 text-[15px]" /> : <Skeleton className="size-7 rounded-md" />}136 title={project ? project.name : <Skeleton className="h-4 w-40" />}137 subtitle={project ? (isActive ? "Active project · new chats start here" : project.archived ? "Archived" : `${data.stats.conversations} chats · ${data.stats.files} files`) : undefined}138 actions={139 project ? (140 <>141 <Button size="sm" onClick={newChat} disabled={project.archived}>142 <MessageSquarePlus /> <span className="hidden sm:inline">New chat in project</span>143 <span className="sm:hidden">New chat</span>144 </Button>145 <RowMenu items={menuItems} title={project.name} alwaysVisible />146 </>147 ) : null148 }149 />150151 <WorkspacePageBody>152 {isLoading && !data ? (153 <div className="space-y-4">154 <Skeleton className="h-16" />155 <Skeleton className="h-9 w-80 max-w-full" />156 <Skeleton className="h-48" />157 </div>158 ) : project && data ? (159 <>160 {project.description || data.stats.conversations || data.stats.files ? (161 <section className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">162 {project.description ? <p className="max-w-2xl text-[14px] text-fg-muted">{project.description}</p> : <span />}163 <dl className="grid shrink-0 grid-cols-4 gap-x-4 text-[12px] text-fg-subtle tabular-nums sm:flex sm:gap-x-5">164 <div>165 <dt className="sr-only">Chats</dt>166 <dd className="inline-flex items-center gap-1"><MessageSquare className="size-3.5" /> {data.stats.conversations}</dd>167 </div>168 <div>169 <dt className="sr-only">Files</dt>170 <dd className="inline-flex items-center gap-1"><FileText className="size-3.5" /> {data.stats.files}</dd>171 </div>172 <div>173 <dt className="sr-only">Context tokens</dt>174 <dd title="Estimated tokens of instructions + files">~{formatTokens(data.stats.fileTokens)} tok</dd>175 </div>176 <div>177 <dt className="sr-only">Cost</dt>178 <dd title="Total cost of chats in this project">{formatUsd(data.stats.totalCostUsd)}</dd>179 </div>180 </dl>181 </section>182 ) : null}183184 <Segmented185 ariaLabel="Project sections"186 value={tab}187 onChange={changeTab}188 className="mt-4 w-full sm:w-auto"189 options={[190 { value: "chats", label: "Chats", icon: <MessageSquare />, count: data.stats.conversations },191 { value: "files", label: "Files", icon: <FileText />, count: data.stats.files },192 { value: "prompts", label: "Prompts", icon: <WandSparkles />, count: data.stats.prompts },193 { value: "setup", label: "Instructions & models", icon: <Settings2 /> },194 { value: "notes", label: "Notes", icon: <StickyNote /> },195 ]}196 />197198 <div className="mt-4">199 {tab === "chats" ? <ProjectConversations projectId={project.id} conversations={data.conversations} onChanged={() => mutate()} onNewChat={newChat} /> : null}200201 {tab === "files" ? (202 <div className="space-y-3">203 <div className="flex items-center gap-2">204 <p className="text-[13px] text-fg-muted tabular-nums">205 {data.files.length} file{data.files.length === 1 ? "" : "s"}206 {data.stats.fileTokens ? ` · ~${formatTokens(data.stats.fileTokens)} tokens` : ""}207 </p>208 <div className="flex-1" />209 <Button asChild size="sm" variant="ghost">210 <Link href={`/app/library?project=${project.id}`}>Open library</Link>211 </Button>212 <UploadButton size="sm" projectId={project.id} onUploaded={() => mutate()} />213 </div>214 <FileList files={data.files} projects={projData?.projects} onChanged={() => 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={<UploadButton projectId={project.id} onUploaded={() => mutate()} />} />215 </div>216 ) : null}217218 {tab === "prompts" ? (219 <div className="space-y-3">220 <div className="flex items-center gap-2">221 <p className="text-[13px] text-fg-muted tabular-nums">222 {data.prompts.length} prompt{data.prompts.length === 1 ? "" : "s"}223 </p>224 <div className="flex-1" />225 <Button asChild size="sm" variant="ghost">226 <Link href="/app/prompts">Open prompt library</Link>227 </Button>228 <Button size="sm" onClick={() => setPromptEditor({ open: true, prompt: null })}>229 <Plus /> New prompt230 </Button>231 </div>232 {data.prompts.length === 0 ? (233 <EmptyState234 icon={<WandSparkles />}235 title="No prompts in this project"236 description="Save the prompts you reuse for this work — with {{variables}} to fill in before sending."237 action={238 <Button onClick={() => setPromptEditor({ open: true, prompt: null })}>239 <Plus /> New prompt240 </Button>241 }242 />243 ) : (244 <ul className="divide-y divide-hairline rounded-xl border border-border bg-bg-elevated">245 {data.prompts.map((p) => (246 <PromptCard key={p.id} prompt={p} actions={promptActions} compact />247 ))}248 </ul>249 )}250 </div>251 ) : null}252253 {tab === "setup" ? <ProjectSetup project={project} onSaved={() => mutate()} /> : null}254 {tab === "notes" ? <ProjectNotes project={project} onSaved={() => mutate()} /> : null}255 </div>256257 {data.stats.lastActivityAt ? <p className="mt-8 text-[11.5px] text-fg-subtle">Last activity {formatRelative(data.stats.lastActivityAt)} · created {formatRelative(project.createdAt)}</p> : null}258 </>259 ) : null}260 </WorkspacePageBody>261262 <ProjectFormSheet open={editing} onOpenChange={setEditing} project={project ?? null} onSaved={() => mutate()} />263 <ConfirmDialog open={deleting} onOpenChange={setDeleting} destructive title={`Delete “${project?.name ?? ""}”?`} description={`Chats stay in your history (${data?.stats.conversations ?? 0}); the project's files (${data?.stats.files ?? 0}) are deleted and its prompts (${data?.stats.prompts ?? 0}) are detached.`} confirmLabel="Delete project" onConfirm={remove} />264 <PromptEditorSheet open={promptEditor.open} onOpenChange={(o) => setPromptEditor((s) => ({ ...s, open: o }))} prompt={promptEditor.prompt} initial={{ projectId: id }} onSaved={() => mutate()} />265 <PromptUseSheet prompt={using} open={using !== null} onOpenChange={(o) => !o && setUsing(null)} />266 <ConfirmDialog267 open={deletingPrompt !== null}268 onOpenChange={(o) => !o && setDeletingPrompt(null)}269 destructive270 title={`Delete “${deletingPrompt?.name ?? ""}”?`}271 confirmLabel="Delete"272 onConfirm={async () => {273 if (!deletingPrompt) return;274 await api(`/api/prompts/${deletingPrompt.id}`, { method: "DELETE" });275 await mutate();276 toast.success("Prompt deleted");277 }}278 />279 </div>280 );281}282