TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { Check, Library, Search } from "lucide-react";4import { ResponsiveDialog } from "@/components/ui/sheet";5import { Button } from "@/components/ui/button";6import { Input } from "@/components/ui/input";7import { ChipRow } from "@/components/ui/segmented";8import { EmptyState, Skeleton } from "@/components/ui/misc";9import { toast } from "@/components/ui/toast";10import { useApi, api } from "@/lib/client/api";11import { useApp } from "@/components/app/store";12import { errorMessage } from "@/lib/client/humanize";13import type { PublicProjectFile, PublicProject } from "@/lib/client/types";14import { formatTokens, formatRelative, cn } from "@/lib/utils";15import { FileThumb } from "./file-list";16import { formatBytes, kindLabel } from "./format";17import { UploadButton } from "./upload-button";1819export interface PickedAttachment {20 id: string;21 kind: string;22 name: string;23 mimeType: string;24 sizeBytes: number;25 width?: number | null;26 height?: number | null;27}2829type Scope = "project" | "global" | "all";30const MAX_PICK = 10;3132/**33 * Pick stored files (project / global library) to attach to a new message.34 * Multi-select (max 10), search, scope chips (this project / global / all), inline upload.35 * Calls POST /api/library/files/attach which copies the files into `message_attachments` and returns36 * pending attachment descriptors compatible with the composer (same shape as POST /api/attachments).37 */38export function FileLibraryPicker({ open, onOpenChange, onPick, projectId }: { open: boolean; onOpenChange: (o: boolean) => void; onPick: (files: PickedAttachment[]) => void; projectId?: string | null }) {39 const { activeProjectId } = useApp();40 const pid = projectId ?? activeProjectId;41 const { data, isLoading, mutate } = useApi<{ files: PublicProjectFile[] }>(open ? "/api/library/files" : null);42 const { data: projData } = useApi<{ projects: PublicProject[] }>(open ? "/api/projects" : null);43 const [selected, setSelected] = React.useState<Set<string>>(new Set());44 const [q, setQ] = React.useState("");45 const [scope, setScope] = React.useState<Scope>(pid ? "project" : "all");46 const [busy, setBusy] = React.useState(false);47 React.useEffect(() => {48 if (!open) return;49 // eslint-disable-next-line react-hooks/set-state-in-effect50 setSelected(new Set());51 setQ("");52 setScope(pid ? "project" : "all");53 }, [open, pid]);5455 const project = React.useMemo(() => (pid ? projData?.projects.find((p) => p.id === pid) : undefined), [pid, projData]);56 const all = React.useMemo(() => data?.files ?? [], [data]);57 const files = React.useMemo(() => {58 const s = q.trim().toLowerCase();59 return all60 .filter((f) => (scope === "project" ? f.projectId === pid : scope === "global" ? !f.projectId : true))61 .filter((f) => (s ? f.name.toLowerCase().includes(s) || (f.description ?? "").toLowerCase().includes(s) || kindLabel(f.kind).toLowerCase().includes(s) : true));62 }, [all, scope, pid, q]);63 const counts = React.useMemo(() => ({ project: all.filter((f) => f.projectId === pid).length, global: all.filter((f) => !f.projectId).length, all: all.length }), [all, pid]);6465 const toggle = (id: string) =>66 setSelected((s) => {67 const n = new Set(s);68 if (n.has(id)) n.delete(id);69 else if (n.size < MAX_PICK) n.add(id);70 else toast.warning(`You can attach up to ${MAX_PICK} files at once`);71 return n;72 });7374 const selectedTokens = React.useMemo(() => all.filter((f) => selected.has(f.id)).reduce((n, f) => n + (f.estimatedTokens ?? 0), 0), [all, selected]);7576 const attach = async () => {77 setBusy(true);78 try {79 const res = await api<{ attachments: PickedAttachment[] }>("/api/library/files/attach", { method: "POST", json: { fileIds: [...selected] } });80 onPick(res.attachments);81 onOpenChange(false);82 } catch (e) {83 toast.error("Could not attach files", errorMessage(e));84 } finally {85 setBusy(false);86 }87 };8889 return (90 <ResponsiveDialog91 open={open}92 onOpenChange={onOpenChange}93 title="Attach from library"94 description={project ? `Files saved to “${project.name}” and your global library.` : "Files saved to your library."}95 size="md"96 snap="full"97 flush98 header={99 <div className="space-y-2 px-4 pb-2 sm:px-6">100 <div className="relative">101 <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-fg-subtle" />102 <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search files…" className="h-10 pl-8 sm:h-9" aria-label="Search files" />103 </div>104 {pid ? <ChipRow value={scope} onChange={setScope} options={[{ value: "project", label: project?.name ?? "This project", count: counts.project }, { value: "global", label: "Global", count: counts.global }, { value: "all", label: "All", count: counts.all }]} /> : null}105 </div>106 }107 footer={108 <div className="flex items-center gap-2">109 <span className="min-w-0 truncate text-[13px] text-fg-muted tabular-nums">110 {selected.size ? `${selected.size} selected${selectedTokens ? ` · ~${formatTokens(selectedTokens)} tokens` : ""}` : "Nothing selected"}111 </span>112 <div className="flex-1" />113 <UploadButton size="sm" variant="ghost" projectId={scope === "global" ? null : pid} onUploaded={() => mutate()}>114 Upload115 </UploadButton>116 <Button disabled={!selected.size} loading={busy} onClick={attach}>117 Attach{selected.size ? ` (${selected.size})` : ""}118 </Button>119 </div>120 }121 >122 <div className="px-4 sm:px-6">123 {isLoading && !data ? (124 <div className="space-y-2 py-2">125 {Array.from({ length: 4 }).map((_, i) => (126 <Skeleton key={i} className="h-12" />127 ))}128 </div>129 ) : files.length === 0 ? (130 <EmptyState icon={<Library />} title={all.length ? "No matching files" : "No stored files yet"} description={all.length ? "Try another search or scope." : "Upload PDFs, images or documents once to a project and reuse them in any chat."} className="my-4 border-0 py-8" />131 ) : (132 <ul className="divide-y divide-hairline">133 {files.map((f) => {134 const on = selected.has(f.id);135 return (136 <li key={f.id}>137 <button type="button" onClick={() => toggle(f.id)} className={cn("flex min-h-[56px] w-full items-center gap-3 py-2 text-left", on && "text-fg")} aria-pressed={on}>138 <FileThumb file={f} />139 <span className="min-w-0 flex-1">140 <span className="block truncate text-[14px] font-medium">{f.name}</span>141 <span className="block truncate text-[12px] text-fg-subtle tabular-nums">142 {kindLabel(f.kind)} · {formatBytes(f.sizeBytes)}143 {f.estimatedTokens ? ` · ~${formatTokens(f.estimatedTokens)} tokens` : ""} · {formatRelative(f.createdAt)}144 </span>145 </span>146 <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>147 {on ? <Check className="size-3.5" /> : null}148 </span>149 </button>150 </li>151 );152 })}153 </ul>154 )}155 </div>156 </ResponsiveDialog>157 );158}159