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%
9.2 KB · 209 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import Link from "next/link";4import { Check, FolderMinus, MessageSquare, MessageSquarePlus, Plus, Search } from "lucide-react";5import { useApp, invalidateConversations, invalidateProjects } from "@/components/app/store";6import { api, useApi } from "@/lib/client/api";7import { errorMessage } from "@/lib/client/humanize";8import { useIsMobile, useLongPress } from "@/lib/client/hooks";9import { Button } from "@/components/ui/button";10import { Input } from "@/components/ui/input";11import { ResponsiveDialog, type ActionSheetItem } from "@/components/ui/sheet";12import { EmptyState, Skeleton } from "@/components/ui/misc";13import { toast } from "@/components/ui/toast";14import { ProviderIcon } from "@/components/brand/provider-icon";15import type { PublicConversation } from "@/lib/client/types";16import { formatRelative, cn } from "@/lib/utils";17import { RowMenu } from "./row-menu";1819interface Unassigned {20  id: string;21  title: string;22  modelKey: string | null;23  updatedAt: string;24  messageCount: number;25}2627/** Conversations tab: rows linking to chats, remove-from-project, and an "Add existing chats" picker. */28export function ProjectConversations({ projectId, conversations, onChanged, onNewChat }: { projectId: string; conversations: PublicConversation[]; onChanged: () => Promise<unknown> | void; onNewChat: () => void }) {29  const [adding, setAdding] = React.useState(false);3031  const remove = async (c: PublicConversation) => {32    try {33      await api(`/api/projects/${projectId}`, { method: "POST", json: { action: "remove-conversations", conversationIds: [c.id] } });34      await Promise.all([onChanged(), invalidateConversations(), invalidateProjects()]);35      toast.success("Removed from project", "The chat is still in your history.");36    } catch (e) {37      toast.error("Could not remove chat", errorMessage(e));38    }39  };4041  return (42    <div className="space-y-3">43      <div className="flex items-center gap-2">44        <p className="text-[13px] text-fg-muted tabular-nums">45          {conversations.length} chat{conversations.length === 1 ? "" : "s"}46        </p>47        <div className="flex-1" />48        <Button size="sm" variant="outline" onClick={() => setAdding(true)}>49          <Plus /> Add existing50        </Button>51        <Button size="sm" onClick={onNewChat}>52          <MessageSquarePlus /> New chat53        </Button>54      </div>55      {conversations.length === 0 ? (56        <EmptyState57          icon={<MessageSquare />}58          title="No chats in this project yet"59          description="Start a chat here — it will use the project's instructions and preferred models — or move existing chats in."60          action={61            <div className="flex flex-col gap-2 sm:flex-row">62              <Button onClick={onNewChat}>63                <MessageSquarePlus /> New chat in this project64              </Button>65              <Button variant="outline" onClick={() => setAdding(true)}>66                <Plus /> Add existing chats67              </Button>68            </div>69          }70        />71      ) : (72        <ul className="divide-y divide-hairline rounded-xl border border-border bg-bg-elevated">73          {conversations.map((c) => (74            <ConversationRow key={c.id} c={c} items={[{ key: "remove", label: "Remove from project", icon: <FolderMinus />, onSelect: () => void remove(c) }]} />75          ))}76        </ul>77      )}78      <AddConversationsSheet projectId={projectId} open={adding} onOpenChange={setAdding} onAdded={onChanged} />79    </div>80  );81}8283function ConversationRow({ c, items }: { c: PublicConversation; items: (ActionSheetItem | "separator")[] }) {84  const { modelsByKey } = useApp();85  const isMobile = useIsMobile();86  const [menu, setMenu] = React.useState(false);87  const press = useLongPress({ onLongPress: () => setMenu(true), disabled: !isMobile });88  const model = c.modelKey ? modelsByKey.get(c.modelKey) : undefined;89  return (90    <li className="group flex min-h-[56px] items-center gap-3 px-3 py-2" {...press}>91      <Link href={`/app/chat/${c.id}`} className="flex min-w-0 flex-1 items-center gap-3">92        <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-bg-muted text-fg-muted" aria-hidden>93          {c.provider ? <ProviderIcon provider={c.provider} size={16} /> : <MessageSquare className="size-4" />}94        </span>95        <span className="min-w-0 flex-1">96          <span className="block truncate text-[14px] font-medium leading-5">{c.title}</span>97          <span className="block truncate text-[12px] leading-4 text-fg-subtle tabular-nums">98            {model?.displayName ?? c.modelKey ?? "No model"} · {c.messageCount} message{c.messageCount === 1 ? "" : "s"} · {formatRelative(c.updatedAt)}99          </span>100        </span>101      </Link>102      <RowMenu items={items} title={c.title} open={menu} onOpenChange={setMenu} />103    </li>104  );105}106107function AddConversationsSheet({ projectId, open, onOpenChange, onAdded }: { projectId: string; open: boolean; onOpenChange: (o: boolean) => void; onAdded: () => Promise<unknown> | void }) {108  const { modelsByKey } = useApp();109  const { data, isLoading } = useApi<{ conversations: Unassigned[] }>(open ? `/api/projects/${projectId}?unassigned=1` : null);110  const [selected, setSelected] = React.useState<Set<string>>(new Set());111  const [q, setQ] = React.useState("");112  const [busy, setBusy] = React.useState(false);113  React.useEffect(() => {114    if (!open) return;115    // eslint-disable-next-line react-hooks/set-state-in-effect116    setSelected(new Set());117    setQ("");118  }, [open]);119  const list = React.useMemo(() => {120    const s = q.trim().toLowerCase();121    return (data?.conversations ?? []).filter((c) => (s ? c.title.toLowerCase().includes(s) : true));122  }, [data, q]);123  const toggle = (id: string) =>124    setSelected((prev) => {125      const n = new Set(prev);126      if (n.has(id)) n.delete(id);127      else n.add(id);128      return n;129    });130  const add = async () => {131    setBusy(true);132    try {133      const res = await api<{ moved: number }>(`/api/projects/${projectId}`, { method: "POST", json: { action: "add-conversations", conversationIds: [...selected] } });134      await Promise.all([onAdded(), invalidateConversations(), invalidateProjects()]);135      toast.success(`${res.moved} chat${res.moved === 1 ? "" : "s"} added to the project`);136      onOpenChange(false);137    } catch (e) {138      toast.error("Could not add chats", errorMessage(e));139    } finally {140      setBusy(false);141    }142  };143  return (144    <ResponsiveDialog145      open={open}146      onOpenChange={onOpenChange}147      title="Add existing chats"148      description="Chats that are not in any project."149      size="md"150      snap="full"151      flush152      header={153        <div className="px-4 pb-2 sm:px-6">154          <div className="relative">155            <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-fg-subtle" />156            <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search chats…" className="h-10 pl-8 sm:h-9" aria-label="Search chats" />157          </div>158        </div>159      }160      footer={161        <div className="flex items-center gap-2">162          <span className="text-[13px] text-fg-muted tabular-nums">{selected.size} selected</span>163          <div className="flex-1" />164          <Button variant="ghost" onClick={() => onOpenChange(false)}>165            Cancel166          </Button>167          <Button disabled={!selected.size} loading={busy} onClick={add}>168            Add{selected.size ? ` (${selected.size})` : ""}169          </Button>170        </div>171      }172    >173      <div className="px-4 sm:px-6">174        {isLoading && !data ? (175          <div className="space-y-2 py-2">176            {Array.from({ length: 5 }).map((_, i) => (177              <Skeleton key={i} className="h-12" />178            ))}179          </div>180        ) : list.length === 0 ? (181          <EmptyState title={q ? "No matches" : "Every chat already belongs to a project"} className="my-4 border-0 py-8" />182        ) : (183          <ul className="divide-y divide-hairline">184            {list.map((c) => {185              const on = selected.has(c.id);186              const model = c.modelKey ? modelsByKey.get(c.modelKey) : undefined;187              return (188                <li key={c.id}>189                  <button type="button" onClick={() => toggle(c.id)} className="flex min-h-[52px] w-full items-center gap-3 py-2 text-left" aria-pressed={on}>190                    <span className="min-w-0 flex-1">191                      <span className="block truncate text-[14px] font-medium">{c.title}</span>192                      <span className="block truncate text-[12px] text-fg-subtle tabular-nums">193                        {model?.displayName ?? c.modelKey ?? "No model"} · {c.messageCount} msg · {formatRelative(c.updatedAt)}194                      </span>195                    </span>196                    <span className={cn("flex size-6 shrink-0 items-center justify-center rounded-full border transition-colors", on ? "border-accent bg-accent text-accent-fg" : "border-border-strong")} aria-hidden>197                      {on ? <Check className="size-3.5" /> : null}198                    </span>199                  </button>200                </li>201              );202            })}203          </ul>204        )}205      </div>206    </ResponsiveDialog>207  );208}209