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 { 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