fa156d6a59
Chat UI: - Each Claude/Codex session keeps its own chat/terminal view; Settings only sets how new sessions open. - Composer + menu: upload, project files and folders (#54, also dragged from the Files/Changes tree), context files, recent uploads, skills, plugins. - Activity side panel (progress, outputs, files read, uploads, memory, skills, connectors) with a file preview; edited-file counts per turn. - Permission-mode chip (Shift+Tab steps, read back from the pane: server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode). - Stop (Esc) while the main agent works; the status is checked per agent (with session_id /api/agents ignores its status filter) and re-read every 5 s while Stop shows. - Dropped images shown in the conversation (GET /api/run/drop/:name, the requester's own drop dir), full-size viewer. - A plain-text paste over 5,000 characters is attached as pasted-text.txt, so no prompt reaches tmux/argv limits (#68). Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the whole panel to a workspace's extra folder (#70). Server mode S3 (one Linux user per account) — experimental, verified with fake sudo plus a P6 managed-settings spike on a test account: - member tmux listing runs in the background (cached, 15 s => unknown); - transcript snapshot copy is async, chunked, temp file + rename; - Express Layer patch only on Express 4; - tmux capturePane/sendKey go to the member's own server. Docs: the multi-user design moved in as docs/plans/server-mode/README.md (the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
/**
|
|
* @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 `/<name>` (`/<plugin>:<name>`) 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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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<Lists> {
|
|
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 (
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
title={title}
|
|
data-testid={testId}
|
|
onClick={onClick}
|
|
onMouseEnter={onHover}
|
|
className={`${ROW} ${active ? "bg-surface-3 text-fg-primary" : ""}`}
|
|
>
|
|
<span className="shrink-0 text-fg-muted">{icon}</span>
|
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
{more && <ChevronRight className="h-3.5 w-3.5 shrink-0 text-fg-muted" />}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/** 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<string[] | null>(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 (
|
|
<>
|
|
<input
|
|
autoFocus
|
|
value={q}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<div className="max-h-72 overflow-y-auto">
|
|
{!items || items.length === 0 ? (
|
|
<p className="px-3 py-1.5 text-xs text-fg-muted">{items ? t("chat.none") : "…"}</p>
|
|
) : (
|
|
items.map((p) => (
|
|
<Row
|
|
key={p}
|
|
icon={
|
|
p.endsWith("/") ? (
|
|
<FolderOpen className="h-4 w-4" />
|
|
) : (
|
|
<FileText className="h-4 w-4" />
|
|
)
|
|
}
|
|
label={p}
|
|
title={p}
|
|
testId={`chat-add-project-${p}`}
|
|
onClick={() => onPick(p)}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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;
|
|
/** `/<name>` 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<Sub | null>(null);
|
|
const [context, setContext] = useState<Attachment[] | null>(null);
|
|
const [recent, setRecent] = useState<Attachment[]>([]);
|
|
const [plugin, setPlugin] = useState<string | null>(null);
|
|
const [lists, setLists] = useState<Lists | null>(null);
|
|
const ref = useRef<HTMLDivElement | null>(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 ? (
|
|
<p className="px-3 py-1.5 text-xs text-fg-muted">{lists ? t("chat.none") : "…"}</p>
|
|
) : (
|
|
items.map((it) => (
|
|
<Row
|
|
key={it.name}
|
|
icon={<ScrollText className="h-4 w-4" />}
|
|
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 (
|
|
<div ref={ref} className="relative">
|
|
<button
|
|
type="button"
|
|
data-testid="chat-add"
|
|
aria-label={t("chat.add")}
|
|
title={t("chat.add")}
|
|
aria-expanded={open}
|
|
onClick={() => (open ? close() : setOpen(true))}
|
|
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</button>
|
|
{open && (
|
|
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}>
|
|
<Row
|
|
icon={<Paperclip className="h-4 w-4" />}
|
|
label={t("chat.upload")}
|
|
testId="chat-add-upload"
|
|
onHover={() => setSub(null)}
|
|
onClick={() => {
|
|
close();
|
|
onUpload();
|
|
}}
|
|
/>
|
|
<Row
|
|
icon={<FolderOpen className="h-4 w-4" />}
|
|
label={t("chat.projectFiles")}
|
|
more
|
|
active={sub === "project"}
|
|
testId="chat-add-project"
|
|
onHover={() => setSub("project")}
|
|
onClick={() => setSub(sub === "project" ? null : "project")}
|
|
/>
|
|
{laneId !== null && (
|
|
<Row
|
|
icon={<FileText className="h-4 w-4" />}
|
|
label={t("chat.contextFiles")}
|
|
more
|
|
active={sub === "context"}
|
|
testId="chat-add-context"
|
|
onHover={() => setSub("context")}
|
|
onClick={() => setSub(sub === "context" ? null : "context")}
|
|
/>
|
|
)}
|
|
<Row
|
|
icon={<History className="h-4 w-4" />}
|
|
label={t("chat.recentUploads")}
|
|
more
|
|
active={sub === "recent"}
|
|
testId="chat-add-recent"
|
|
onHover={() => setSub("recent")}
|
|
onClick={() => setSub(sub === "recent" ? null : "recent")}
|
|
/>
|
|
{skills && (
|
|
<>
|
|
<Row
|
|
icon={<ScrollText className="h-4 w-4" />}
|
|
label={t("chat.skills")}
|
|
more
|
|
active={sub === "skills"}
|
|
testId="chat-add-skills"
|
|
onHover={() => setSub("skills")}
|
|
onClick={() => setSub(sub === "skills" ? null : "skills")}
|
|
/>
|
|
<Row
|
|
icon={<Plug className="h-4 w-4" />}
|
|
label={t("chat.plugins")}
|
|
more
|
|
active={sub === "plugins"}
|
|
testId="chat-add-plugins"
|
|
onHover={() => setSub("plugins")}
|
|
onClick={() => setSub(sub === "plugins" ? null : "plugins")}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{sub === "project" && (
|
|
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}>
|
|
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
|
|
</div>
|
|
)}
|
|
|
|
{(sub === "context" || sub === "recent") && (
|
|
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
|
<div className="max-h-72 overflow-y-auto">
|
|
{(sub === "context" ? context : recent)?.length ? (
|
|
(sub === "context" ? context! : recent).map((it) => (
|
|
<Row
|
|
key={it.path}
|
|
icon={<FileText className="h-4 w-4" />}
|
|
label={it.name}
|
|
title={it.path.replace(/^@/, "")}
|
|
testId={`chat-add-${sub}-${it.name}`}
|
|
onClick={() => attach(it)}
|
|
/>
|
|
))
|
|
) : (
|
|
<p className="px-3 py-1.5 text-xs text-fg-muted">
|
|
{sub === "context" && !context ? "…" : t("chat.none")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{sub === "skills" && (
|
|
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
|
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
|
|
<div className="my-1 border-t border-border" />
|
|
<Row
|
|
icon={<Settings2 className="h-4 w-4" />}
|
|
label={t("chat.manageSkills")}
|
|
onClick={() => manage("skills")}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{sub === "plugins" && (
|
|
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
|
<div className="max-h-72 overflow-y-auto">
|
|
{lists && lists.plugins.length === 0 && (
|
|
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
|
|
)}
|
|
{!lists && <p className="px-3 py-1.5 text-xs text-fg-muted">…</p>}
|
|
{lists?.plugins.map((p) => (
|
|
<Row
|
|
key={p.name}
|
|
icon={<Plug className="h-4 w-4" />}
|
|
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)}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="my-1 border-t border-border" />
|
|
<Row
|
|
icon={<Settings2 className="h-4 w-4" />}
|
|
label={t("chat.managePlugins")}
|
|
onClick={() => manage("plugins")}
|
|
/>
|
|
{shownPlugin && (
|
|
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
|
<div className="max-h-72 overflow-y-auto">
|
|
{entries(
|
|
shownPlugin.items,
|
|
`${shownPlugin.name}:`,
|
|
t("chat.fromPlugin", { name: shownPlugin.name })
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|