SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
14.3 KB · 282 lines tsx
Raw Blame History
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