/** * @file ChatAddMenu.tsx * @description The Chat view's `+` menu, Claude Desktop's: Upload or add * files (the prompt box's own picker), what is already there to attach — * Project files (a search of the box's folder, folders too: * `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context * files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's, * `lib/promptAttach.ts`) —, Skills (the user's and the project's) * and Plugins → one plugin → the skills and commands it ships. Picking one * puts `/` (`/:`) at the front of the message — the same * text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists * are read when the menu first opens (`GET /api/cc-config/skills|plugins`), * and Manage opens the Claude config page on that tab. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { ChevronRight, FileText, FolderOpen, History, Paperclip, Plug, Plus, ScrollText, Settings2, } from "lucide-react"; import { api } from "../../lib/api"; import { projectAttachment, recentUploads, type Attachment } from "../../lib/promptAttach"; interface Entry { name: string; description?: string; } interface Lists { skills: Entry[]; plugins: { name: string; items: Entry[] }[]; } const byName = (a: Entry, b: Entry) => a.name.localeCompare(b.name); function loadLists(cwd: string): Promise { return Promise.all([ api.ccConfig.skills(undefined, cwd).catch(() => ({ items: [] })), api.ccConfig.plugins().catch(() => ({ plugins: [] })), ]).then(([sk, pl]) => ({ skills: sk.items .map((s) => ({ name: s.name, description: s.frontmatter?.description })) .filter((s, i, all) => all.findIndex((o) => o.name === s.name) === i) .sort(byName), plugins: pl.plugins .filter((p) => p.enabled !== false && p.contributes) .map((p) => ({ name: p.name, items: [...p.contributes!.items.skills, ...p.contributes!.items.commands] .map((it) => ({ name: it.name, description: it.description ?? undefined })) .filter((it, i, all) => all.findIndex((o) => o.name === it.name) === i) .sort(byName), })) .filter((p, i, all) => p.items.length > 0 && all.findIndex((o) => o.name === p.name) === i) .sort(byName), })); } const PANEL = "absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in"; const ROW = "flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"; function Row({ icon, label, more, active, onClick, onHover, title, testId, }: { icon: ReactNode; label: string; more?: boolean; active?: boolean; onClick: () => void; onHover?: () => void; title?: string; testId?: string; }) { return ( ); } /** Project files: a search of the box's folder, shortest paths first. */ function ProjectFiles({ cwd, onPick }: { cwd: string; onPick: (rel: string) => void }) { const { t } = useTranslation("run"); const [q, setQ] = useState(""); const [items, setItems] = useState(null); useEffect(() => { let live = true; const tid = window.setTimeout(() => { api.run .files(cwd, q, true) .then((r) => live && setItems(r.items)) .catch(() => live && setItems([])); }, 120); return () => { live = false; window.clearTimeout(tid); }; }, [cwd, q]); return ( <> setQ(e.target.value)} placeholder={t("chat.searchFiles")} data-testid="chat-add-project-search" className="mx-2 mb-1 w-[calc(100%-1rem)] rounded-md border border-border bg-surface-2 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent/50 focus:outline-none" />
{!items || items.length === 0 ? (

{items ? t("chat.none") : "…"}

) : ( items.map((p) => ( ) : ( ) } label={p} title={p} testId={`chat-add-project-${p}`} onClick={() => onPick(p)} /> )) )}
); } type Sub = "project" | "context" | "recent" | "skills" | "plugins"; export function ChatAddMenu({ cwd, laneId = null, skills, onUpload, onInsert, onAttach, }: { cwd: string; /** The workspace, for its Context files; none hides them. */ laneId?: number | null; /** Offer Skills and Plugins: Claude only, they are Claude Code's. */ skills: boolean; onUpload: () => void; /** `/` picked. */ onInsert: (command: string) => void; /** An existing file picked: a project path, a context file, an upload. */ onAttach: (items: Attachment[]) => void; }) { const { t } = useTranslation("run"); const navigate = useNavigate(); const [open, setOpen] = useState(false); const [sub, setSub] = useState(null); const [context, setContext] = useState(null); const [recent, setRecent] = useState([]); const [plugin, setPlugin] = useState(null); const [lists, setLists] = useState(null); const ref = useRef(null); useEffect(() => { if (!open) return; if (skills && !lists) void loadLists(cwd).then(setLists); const away = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); }; const esc = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); document.addEventListener("mousedown", away); document.addEventListener("keydown", esc); return () => { document.removeEventListener("mousedown", away); document.removeEventListener("keydown", esc); }; }, [open, skills, lists, cwd]); // Read again at each opening: either list may have grown since. useEffect(() => { if (!open) return; setRecent(recentUploads()); setContext(null); if (laneId === null) return; let live = true; api.lanes .sources(laneId) .then( (v) => live && setContext( v.sources .filter((s) => s.kind === "file" && !s.missing) .map((s) => ({ path: `@${s.path}`, name: s.name })) ) ) .catch(() => live && setContext([])); return () => { live = false; }; }, [open, laneId]); const close = () => { setOpen(false); setSub(null); setPlugin(null); }; const pick = (command: string) => { close(); onInsert(command); }; const attach = (item: Attachment) => { close(); onAttach([item]); }; const manage = (tab: "skills" | "plugins") => { close(); navigate(`/cc-config?tab=${tab}`); }; const entries = (items: Entry[], prefix: string, from?: string) => items.length === 0 ? (

{lists ? t("chat.none") : "…"}

) : ( items.map((it) => ( } label={it.name} title={[it.description, from].filter(Boolean).join("\n") || undefined} testId={`chat-add-skill-${prefix}${it.name}`} onClick={() => pick(`/${prefix}${it.name}`)} /> )) ); const shownPlugin = lists?.plugins.find((p) => p.name === plugin); return (
{open && (
} label={t("chat.upload")} testId="chat-add-upload" onHover={() => setSub(null)} onClick={() => { close(); onUpload(); }} /> } label={t("chat.projectFiles")} more active={sub === "project"} testId="chat-add-project" onHover={() => setSub("project")} onClick={() => setSub(sub === "project" ? null : "project")} /> {laneId !== null && ( } label={t("chat.contextFiles")} more active={sub === "context"} testId="chat-add-context" onHover={() => setSub("context")} onClick={() => setSub(sub === "context" ? null : "context")} /> )} } label={t("chat.recentUploads")} more active={sub === "recent"} testId="chat-add-recent" onHover={() => setSub("recent")} onClick={() => setSub(sub === "recent" ? null : "recent")} /> {skills && ( <> } label={t("chat.skills")} more active={sub === "skills"} testId="chat-add-skills" onHover={() => setSub("skills")} onClick={() => setSub(sub === "skills" ? null : "skills")} /> } label={t("chat.plugins")} more active={sub === "plugins"} testId="chat-add-plugins" onHover={() => setSub("plugins")} onClick={() => setSub(sub === "plugins" ? null : "plugins")} /> )} {sub === "project" && (
attach(projectAttachment(rel))} />
)} {(sub === "context" || sub === "recent") && (
{(sub === "context" ? context : recent)?.length ? ( (sub === "context" ? context! : recent).map((it) => ( } label={it.name} title={it.path.replace(/^@/, "")} testId={`chat-add-${sub}-${it.name}`} onClick={() => attach(it)} /> )) ) : (

{sub === "context" && !context ? "…" : t("chat.none")}

)}
)} {sub === "skills" && (
{entries(lists?.skills ?? [], "")}
} label={t("chat.manageSkills")} onClick={() => manage("skills")} />
)} {sub === "plugins" && (
{lists && lists.plugins.length === 0 && (

{t("chat.none")}

)} {!lists &&

…

} {lists?.plugins.map((p) => ( } label={p.name} more active={plugin === p.name} testId={`chat-add-plugin-${p.name}`} onHover={() => setPlugin(p.name)} onClick={() => setPlugin(plugin === p.name ? null : p.name)} /> ))}
} label={t("chat.managePlugins")} onClick={() => manage("plugins")} /> {shownPlugin && (
{entries( shownPlugin.items, `${shownPlugin.name}:`, t("chat.fromPlugin", { name: shownPlugin.name }) )}
)}
)}
)}
); }