feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars

- Artifacts hub: upload a file from the computer; make a skill by uploading a
  Markdown file, files, a folder or a .zip/.skill, or by writing it in the
  dialog; add plugins by picking from the list a marketplace link answers
  (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub.
- Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid).
- Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB,
  <data dir>/avatars/), shown with name and role at the sidebar's foot and on
  the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at).
- Users page: per-user storage (GET /api/users/storage, measured in a worker)
  and account-pool spend/tokens per account (server/lib/pool-usage.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 17:21:33 +07:00
parent 8ed5018a2f
commit 7a88b74016
63 changed files with 3833 additions and 354 deletions
+55 -17
View File
@@ -1,6 +1,6 @@
/**
* @file Sidebar.tsx
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot — in server mode with the signed-in account above them (its avatar, name and role, linking to Settings → Account, and Log out). It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -79,6 +79,7 @@ import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
import { BRAND_LOGO, BRAND_MARK } from "../lib/brand";
import { useAuth, usePermissions } from "./AuthGate";
import { UserAvatar } from "./users/UserAvatar";
import { eventBus } from "../lib/eventBus";
import { WorkspaceTree } from "./WorkspaceTree";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
@@ -540,6 +541,59 @@ export function Sidebar({
)}
</div>
{/* Server mode: who is signed in — avatar, name and role, opening
Settings → Account — and Log out. On the rail they stack. */}
{auth.state.mode === "server" &&
auth.state.user &&
(() => {
const user = auth.state.user;
const name = user.displayName || user.username;
const role = t(`users:roles.${user.role}`);
const signedIn = t("common:auth.signedInAs", { name, role });
return (
<div
data-testid="sidebar-account"
className={`flex flex-shrink-0 gap-1 border-t border-border px-2 py-2 ${
collapsed ? "flex-col items-center" : "items-center"
}`}
>
<NavLink
to={settingsHref("account", location)}
title={signedIn}
aria-label={signedIn}
className={`flex min-w-0 items-center gap-2 rounded-md transition-colors hover:bg-surface-3 ${
collapsed ? "justify-center p-1" : "flex-1 px-1.5 py-1"
}`}
>
<UserAvatar
name={name}
avatarAt={user.avatarAt}
className={collapsed ? "h-7 w-7 text-[11px]" : "h-8 w-8 text-xs"}
/>
{!collapsed && (
<span className="min-w-0 leading-tight">
<span className="block truncate text-sm font-medium text-fg-primary">
{name}
</span>
<span className="block truncate text-[11px] text-fg-muted">{role}</span>
</span>
)}
</NavLink>
<button
type="button"
onClick={() => void auth.logout()}
title={`${signedIn} — ${t("common:auth.logout")}`}
aria-label={t("common:auth.logout")}
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "w-full py-2" : "h-8 w-8 flex-shrink-0"
}`}
>
<LogOut className="h-4 w-4" />
</button>
</div>
);
})()}
{/* Settings and the collapse toggle stick to the foot, out of the
scrolling nav. On the rail they stack. */}
<div
@@ -555,22 +609,6 @@ export function Sidebar({
<Settings className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{t("nav:settings")}</span>}
</NavLink>
{auth.state.mode === "server" && auth.state.user && (
<button
type="button"
onClick={() => void auth.logout()}
title={`${t("common:auth.signedInAs", {
name: auth.state.user.displayName || auth.state.user.username,
role: auth.state.user.role,
})} — ${t("common:auth.logout")}`}
aria-label={t("common:auth.logout")}
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
}`}
>
<LogOut className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={onToggle}
+11 -8
View File
@@ -10,8 +10,8 @@
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
* expanded lives in `projectTreePrefs.ts` (`expanded`). Server mode, admin or
* analyst: EVERY workspace, grouped under its owner (`owner_display_name`,
* else `owner_name`; yours
* analyst: EVERY workspace, grouped under its owner (their avatar —
* `owner_avatar_at`, `UserAvatar` — and `owner_display_name`, else `owner_name`; yours
* first, no owner last, groups foldable — `collapsedOwners`); one this user
* may not change (`canEditLane`) is read only — no menu, no new session, and a
* session opens its analysis page (`/sessions/<id>`), never a pane.
@@ -49,6 +49,7 @@ import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePr
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
import { UserAvatar } from "./users/UserAvatar";
// The sidebar is in the entry chunk; its two modals (and the run form the add
// modal pulls in) load when first opened.
@@ -366,12 +367,14 @@ export function WorkspaceTree() {
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)}
<span
aria-hidden
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-4 text-[9px] font-semibold uppercase text-fg-secondary"
>
{ownerShown(lane)[0] ?? "?"}
</span>
<UserAvatar
name={ownerShown(lane) || "?"}
userId={lane.owner_id ?? undefined}
avatarAt={lane.owner_avatar_at}
letters={1}
className="h-4 w-4 text-[9px]"
initialsClassName="bg-surface-4 text-fg-secondary"
/>
<span className="truncate">{ownerLabel}</span>
<span className="ml-auto shrink-0 tabular-nums">
{sorted.filter((l) => ownerKey(l) === owner).length}
@@ -0,0 +1,73 @@
/**
* @file Sidebar.account.test.tsx
* @description The sidebar's account row (server mode): the signed-in user's
* avatar — or initials without one — name and role, linking to Settings →
* Account, beside Sign out; local mode shows no row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
const auth = vi.hoisted(() => ({
state: { mode: "local" } as Record<string, unknown>,
logout: vi.fn(),
updateUser: vi.fn(),
}));
vi.mock("../AuthGate", () => ({
useAuth: () => auth,
usePermissions: () => ({
isAdmin: auth.state.mode !== "server",
local: auth.state.mode !== "server",
seesAll: true,
canEditLane: () => true,
}),
}));
import { Sidebar } from "../Sidebar";
const renderSidebar = (collapsed = false) =>
render(
<MemoryRouter>
<Sidebar
wsConnected
collapsed={collapsed}
onToggle={() => {}}
connectionOpen={false}
onConnectionOpenChange={() => {}}
/>
</MemoryRouter>
);
describe("Sidebar account row", () => {
it("shows the avatar, name and role, linking to Settings → Account", () => {
auth.state = {
mode: "server",
user: { id: 2, username: "mia", role: "developer", displayName: "Mia Tran", avatarAt: "t1" },
};
renderSidebar();
const row = screen.getByTestId("sidebar-account");
expect(row.textContent).toContain("Mia Tran");
expect(row.textContent).toContain("Developer");
expect(within(row).getByTestId("user-avatar-img").getAttribute("src")).toBe(
"/api/me/avatar?v=t1"
);
expect(within(row).getAllByRole("link")[0]?.getAttribute("href")).toBe("/settings/account");
expect(within(row).getByRole("button", { name: "Sign out" })).toBeTruthy();
});
it("falls back to initials, and keeps only the avatar on the rail", () => {
auth.state = { mode: "server", user: { id: 3, username: "leo", role: "admin" } };
renderSidebar(true);
const row = screen.getByTestId("sidebar-account");
expect(within(row).getByTestId("user-avatar-initials").textContent).toBe("le");
expect(row.textContent).not.toContain("Admin");
});
it("has no account row in local mode", () => {
auth.state = { mode: "local" };
renderSidebar();
expect(screen.queryByTestId("sidebar-account")).toBeNull();
});
});
@@ -1,7 +1,8 @@
/**
* @file WorkspaceTree.owners.test.tsx
* @description Server mode, the sidebar workspace list for an analyst: every
* user's workspaces, grouped under their owner (yours first, no owner last),
* user's workspaces, grouped under their owner (with their avatar, else an
* initial; yours first, no owner last),
* groups folding and remembered; another user's workspace is read only — no
* menu, no new session, and a session opens its analysis page, never a pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -30,7 +31,11 @@ const lane = (id: number, owner: number | null, name: string | null, title: stri
}) as unknown as Lane;
const lanes = [
{ ...lane(1, 3, "bob", "Bob app"), owner_display_name: "Bob Nguyen" } as Lane,
{
...lane(1, 3, "bob", "Bob app"),
owner_display_name: "Bob Nguyen",
owner_avatar_at: "t1",
} as Lane,
lane(2, null, null, "Old"),
lane(3, ME.id, "ana", "Mine"),
lane(4, 1, "admin", "Admin site"),
@@ -61,6 +66,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
})),
},
run: { list: vi.fn(async () => ({ items: [] })) },
users: {
avatarUrl: (id: number, at?: string | null) =>
at ? `/api/users/${id}/avatar?v=${at}` : null,
},
me: { avatarUrl: () => null },
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
},
};
@@ -118,6 +128,15 @@ describe("WorkspaceTree for an analyst", () => {
await screen.findByTestId("workspace-row-1");
expect(screen.getByTestId("workspace-owner-3")).toHaveTextContent("Bob Nguyen");
expect(screen.getByTestId("workspace-owner-3")).not.toHaveTextContent("bob");
// Bob set a picture: shown beside the name; Ana did not: her initial.
expect(
within(screen.getByTestId("workspace-owner-3"))
.getByTestId("user-avatar-img")
.getAttribute("src")
).toBe("/api/users/3/avatar?v=t1");
expect(
within(screen.getByTestId("workspace-owner-2")).getByTestId("user-avatar-initials")
).toHaveTextContent("a");
expect(screen.getByTestId("workspace-owner-1")).toHaveTextContent("admin");
});
@@ -0,0 +1,928 @@
/**
* @file ArtifactAddPanels.tsx
* @description The Artifacts page's Add dialogs for a skill, a plugin and an
* uploaded file (server/lib/artifacts.js):
* - `SkillAddPanel` — three ways to make a skill: **Upload** (one Markdown
* file that becomes SKILL.md, several files, a whole folder — picked or
* dropped — or a .zip/.skill; an upload holding several skills makes one
* artifact each), **Write** (name, description, instructions) and
* **Existing** (a folder in the user's ~/.claude/skills or a workspace's
* .claude/skills). The user names the skill; the name is previewed as the
* folder and `/command` it installs as (`skillSlug`, the server's rule).
* - `PluginAddPanel` — paste a marketplace link (a git https link, a forge
* web link, `owner/repo` or a marketplace.json link), read its plugin list
* and tick the ones to add; or type `name@marketplace` by hand, which is
* also where a link the server cannot read (ssh, a private repo) ends up.
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { FileText, FolderOpen, Package, Sparkles, Upload, X } from "lucide-react";
import { api, type MarketplaceInfo } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { toast } from "../../lib/toast";
const field = "w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
const label = "block text-[11px] font-medium text-fg-secondary";
const hint = "text-[11px] text-fg-muted";
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
export function skillSlug(text: string): string {
return text
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/đ/g, "d")
.replace(/Đ/g, "D")
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/^[-._]+|-+$/g, "")
.slice(0, 100);
}
/** `field:` of a Markdown file's frontmatter, or "". */
export function frontmatterField(text: string, name: string): string {
const fm = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text);
if (!fm) return "";
const m = new RegExp(`^${name}:\\s*(.*)$`, "m").exec(fm[1] ?? "");
return (m?.[1] ?? "").trim().replace(/^(["'])(.*)\1$/, "$2");
}
type Picked = { file: File; path: string };
/** A small file's text ("" on failure), only to prefill the name/description. */
function readText(file: File): Promise<string> {
if (file.size > 1024 * 1024) return Promise.resolve("");
return new Promise((ok) => {
const r = new FileReader();
r.onload = () => ok(typeof r.result === "string" ? r.result : "");
r.onerror = () => ok("");
r.readAsText(file);
});
}
const JUNK = /(^|\/)(__MACOSX|\.DS_Store|Thumbs\.db|desktop\.ini|\.git|node_modules)(\/|$)/;
const isSkillMd = (p: string) => /(^|\/)skill\.md$/i.test(p);
const isArchive = (p: string) => /\.(zip|skill)$/i.test(p);
const isText = (p: string) => /\.(md|markdown|txt)$/i.test(p);
/** Every file under what was dropped, folders walked (webkitGetAsEntry). */
async function droppedFiles(e: DragEvent): Promise<Picked[]> {
const items = Array.from(e.dataTransfer.items || []);
const entries = items
.map((i) => (i.webkitGetAsEntry ? i.webkitGetAsEntry() : null))
.filter((x): x is FileSystemEntry => !!x);
if (!entries.length) {
return Array.from(e.dataTransfer.files).map((file) => ({ file, path: file.name }));
}
const out: Picked[] = [];
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
if (entry.isFile) {
const file = await new Promise<File>((ok, fail) =>
(entry as FileSystemFileEntry).file(ok, fail)
);
out.push({ file, path: prefix + entry.name });
return;
}
const reader = (entry as FileSystemDirectoryEntry).createReader();
// readEntries answers in batches until an empty one.
for (;;) {
const batch = await new Promise<FileSystemEntry[]>((ok, fail) =>
reader.readEntries(ok, fail)
);
if (!batch.length) break;
for (const child of batch) await walk(child, `${prefix}${entry.name}/`);
}
};
for (const entry of entries) await walk(entry, "");
return out;
}
/** What an upload will make, guessed the way the server decides it. */
function describeUpload(files: Picked[]): {
kind: "none" | "single-md" | "archive" | "one" | "many" | "no-skill-md";
count: number;
folder: string;
} {
const real = files.filter((f) => !JUNK.test(f.path));
if (!real.length) return { kind: "none", count: 0, folder: "" };
if (real.length === 1 && isArchive(real[0]!.path))
return { kind: "archive", count: 0, folder: "" };
const roots = [
...new Set(
real.filter((f) => isSkillMd(f.path)).map((f) => f.path.split("/").slice(0, -1).join("/"))
),
].sort((a, b) => a.length - b.length);
const under = (dir: string, p: string) => dir === "" || p === dir || p.startsWith(`${dir}/`);
const top = roots.filter((r, i) => !roots.slice(0, i).some((o) => under(o, r)));
if (!top.length) {
return real.length === 1 && isText(real[0]!.path)
? { kind: "single-md", count: 1, folder: "" }
: { kind: "no-skill-md", count: 0, folder: "" };
}
if (top.length === 1) return { kind: "one", count: 1, folder: top[0]!.split("/").pop() ?? "" };
return { kind: "many", count: top.length, folder: "" };
}
function Tabs<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: { id: T; label: string }[];
onChange: (v: T) => void;
}) {
return (
<div role="tablist" className="flex rounded border border-border bg-surface-2 p-0.5">
{options.map((o) => (
<button
key={o.id}
type="button"
role="tab"
aria-selected={value === o.id}
onClick={() => onChange(o.id)}
className={`flex-1 rounded px-2 py-1 text-xs ${
value === o.id
? "bg-accent-solid text-white"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{o.label}
</button>
))}
</div>
);
}
/** The name field with the folder/command it becomes. */
function SkillNameField({
value,
onChange,
disabled,
required,
}: {
value: string;
onChange: (v: string) => void;
disabled?: boolean;
required?: boolean;
}) {
const { t } = useTranslation("artifacts");
const slug = skillSlug(value);
return (
<div className="space-y-0.5">
<label className={label}>
{t("skill.name")}
{required ? " *" : ""}
<input
className={`${field} mt-0.5`}
value={value}
disabled={disabled}
placeholder="release-notes"
onChange={(e) => onChange(e.target.value)}
/>
</label>
{value.trim() && (
<p className={hint} data-testid="skill-slug">
{slug ? t("skill.slugHint", { slug }) : t("skill.slugBad")}
</p>
)}
</div>
);
}
// ── Skill ─────────────────────────────────────────────────────────────────
export function SkillAddPanel({
lanes,
onAdded,
onClose,
}: {
lanes: Lane[];
onAdded: () => void;
onClose: () => void;
}) {
const { t } = useTranslation("artifacts");
const [mode, setMode] = useState<"upload" | "write" | "existing">("upload");
const [busy, setBusy] = useState(false);
const [name, setName] = useState("");
const [nameTouched, setNameTouched] = useState(false);
const [description, setDescription] = useState("");
const [descTouched, setDescTouched] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [files, setFiles] = useState<Picked[]>([]);
const [dragging, setDragging] = useState(false);
const [laneId, setLaneId] = useState<number | null>(null);
const [skills, setSkills] = useState<{ name: string; from: "user" | "workspace" }[] | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
// `webkitdirectory` is not in React's typings.
useEffect(() => {
folderInput.current?.setAttribute("webkitdirectory", "");
}, [mode]);
useEffect(() => {
if (mode !== "existing") return;
api.artifacts
.skills(laneId ?? undefined)
.then((r) => setSkills(r.items))
.catch(() => setSkills([]));
}, [mode, laneId]);
const shape = useMemo(() => describeUpload(files), [files]);
// Fill the name and description from what was picked, unless typed.
useEffect(() => {
if (!files.length) return;
const md =
files.find((f) => isSkillMd(f.path) && !JUNK.test(f.path)) ??
(shape.kind === "single-md" ? files.find((f) => !JUNK.test(f.path)) : undefined);
const stem = (p: string) => (p.split("/").pop() ?? "").replace(/\.[^.]+$/, "");
let live = true;
void (async () => {
const text = md ? await readText(md.file) : "";
if (!live) return;
const fmName = frontmatterField(text, "name");
const fmDesc = frontmatterField(text, "description");
if (!nameTouched) {
const guess =
shape.kind === "single-md"
? fmName || stem(md?.path ?? "")
: shape.kind === "one"
? fmName || shape.folder
: shape.kind === "archive"
? stem(files[0]!.path)
: "";
setName(guess);
}
if (!descTouched && fmDesc) setDescription(fmDesc);
})();
return () => {
live = false;
};
// Refill only when the pick changes, never on a typed name.
}, [files, shape]);
const pick = (list: FileList | null) => {
if (!list?.length) return;
setFiles(
Array.from(list).map((file) => ({
file,
path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
}))
);
};
const run = async (fn: () => Promise<number>) => {
setBusy(true);
try {
const n = await fn();
toast.success(n > 1 ? t("skill.addedMany", { count: n }) : t("added"));
onAdded();
onClose();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
};
const many = shape.kind === "many";
const total = files.reduce((n, f) => n + f.file.size, 0);
const meta = (
<>
<label className={label}>
{t("skill.description")}
<textarea
className={`${field} mt-0.5`}
rows={2}
value={description}
disabled={many}
placeholder={t("skill.descriptionHint")}
onChange={(e) => {
setDescTouched(true);
setDescription(e.target.value);
}}
/>
</label>
<label className={label}>
{t("form.title")}
<input
className={`${field} mt-0.5`}
value={title}
disabled={many}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
</>
);
return (
<div className="space-y-3">
<Tabs
value={mode}
onChange={setMode}
options={[
{ id: "upload", label: t("skill.tab.upload") },
{ id: "write", label: t("skill.tab.write") },
{ id: "existing", label: t("skill.tab.existing") },
]}
/>
{mode === "upload" && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
const r = await api.artifacts.uploadSkills(files, {
name: many ? undefined : name.trim(),
description: many ? undefined : description.trim(),
title: many ? undefined : title.trim(),
});
return r.items.length;
});
}}
>
<div
data-testid="skill-drop"
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
void droppedFiles(e).then((list) => list.length && setFiles(list));
}}
className={`rounded-lg border border-dashed p-4 text-center ${
dragging ? "border-accent bg-accent/10" : "border-border"
}`}
>
<Upload className="mx-auto mb-1 h-5 w-5 text-fg-muted" />
<p className="text-xs text-fg-secondary">{t("skill.dropHint")}</p>
<div className="mt-2 flex flex-wrap justify-center gap-2">
<button
type="button"
className="btn-ghost text-xs"
onClick={() => fileInput.current?.click()}
>
<FileText className="h-3.5 w-3.5" /> {t("skill.pickFiles")}
</button>
<button
type="button"
className="btn-ghost text-xs"
onClick={() => folderInput.current?.click()}
>
<FolderOpen className="h-3.5 w-3.5" /> {t("skill.pickFolder")}
</button>
</div>
<input
ref={fileInput}
type="file"
multiple
hidden
data-testid="skill-files"
onChange={(e) => {
pick(e.target.files);
e.target.value = "";
}}
/>
<input
ref={folderInput}
type="file"
multiple
hidden
data-testid="skill-folder"
onChange={(e) => {
pick(e.target.files);
e.target.value = "";
}}
/>
</div>
{files.length > 0 && (
<div className="rounded border border-border bg-surface-2 p-2">
<div className="mb-1 flex items-center gap-2">
<p className="flex-1 text-xs text-fg-primary" data-testid="skill-shape">
{t(`skill.shape.${shape.kind}`, { count: shape.count })}
</p>
<span className={hint}>
{t("files", { count: files.length })} · {Math.ceil(total / 1024)} KB
</span>
<button
type="button"
aria-label={t("skill.clear")}
className="text-fg-muted hover:text-fg-primary"
onClick={() => {
setFiles([]);
setNameTouched(false);
setDescTouched(false);
}}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<ul className="max-h-28 overflow-auto font-mono text-[11px] text-fg-secondary">
{files.slice(0, 200).map((f) => (
<li
key={f.path}
className={`truncate ${JUNK.test(f.path) ? "line-through opacity-50" : ""}`}
>
{f.path}
</li>
))}
</ul>
</div>
)}
{many ? (
<p className={hint}>{t("skill.manyHint")}</p>
) : (
<SkillNameField
value={name}
onChange={(v) => {
setNameTouched(true);
setName(v);
}}
required={shape.kind === "single-md"}
/>
)}
{meta}
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={
busy ||
!files.length ||
shape.kind === "no-skill-md" ||
(shape.kind === "single-md" && !skillSlug(name))
}
>
{busy ? t("skill.uploading") : t("form.save")}
</button>
</div>
</form>
)}
{mode === "write" && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
await api.artifacts.writeSkill({
name: name.trim(),
description: description.trim(),
content,
title: title.trim() || undefined,
});
return 1;
});
}}
>
<SkillNameField value={name} onChange={setName} required />
<label className={label}>
{t("skill.description")} *
<textarea
className={`${field} mt-0.5`}
rows={2}
value={description}
placeholder={t("skill.descriptionHint")}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
<label className={label}>
{t("skill.content")} *
<textarea
className={`${field} mt-0.5 font-mono text-xs`}
rows={10}
value={content}
placeholder={t("skill.contentHint")}
onChange={(e) => setContent(e.target.value)}
/>
</label>
<label className={label}>
{t("form.title")}
<input
className={`${field} mt-0.5`}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={
busy ||
!skillSlug(name) ||
!content.trim() ||
(!description.trim() && !frontmatterField(content, "description"))
}
>
{t("form.save")}
</button>
</div>
</form>
)}
{mode === "existing" && (
<div className="space-y-2">
<select
className={field}
aria-label={t("form.workspace")}
value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">{t("form.pickWorkspace")}</option>
{lanes.map((l) => (
<option key={l.id} value={l.id}>
{l.title || l.cwd}
</option>
))}
</select>
{skills && skills.length === 0 && <p className={hint}>{t("form.noSkills")}</p>}
{skills && skills.length > 0 && (
<ul className="max-h-72 space-y-0.5 overflow-auto">
{skills.map((s) => (
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
<span className="text-fg-muted">
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
</span>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy}
onClick={() =>
void run(async () => {
await api.artifacts.addSkill({
name: s.name,
...(s.from === "workspace" && laneId ? { laneId } : {}),
title: title || undefined,
description: description || undefined,
});
return 1;
})
}
>
{t("form.save")}
</button>
</li>
))}
</ul>
)}
<input
className={field}
placeholder={t("form.title")}
aria-label={t("form.title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className={field}
rows={2}
placeholder={t("form.description")}
aria-label={t("form.description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
)}
</div>
);
}
// ── Plugin ────────────────────────────────────────────────────────────────
const MANUAL_CODES = new Set(["ENEEDLOGIN", "ESSHMARKET", "ENOTMARKET", "EMARKETFETCH"]);
export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onClose: () => void }) {
const { t } = useTranslation("artifacts");
const [link, setLink] = useState("");
const [busy, setBusy] = useState(false);
const [info, setInfo] = useState<MarketplaceInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [picked, setPicked] = useState<Set<string>>(new Set());
const [have, setHave] = useState<Set<string>>(new Set());
const [manual, setManual] = useState(false);
const [key, setKey] = useState("");
const [market, setMarket] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
useEffect(() => {
api.artifacts
.list({ scope: "mine", kind: "plugin" })
.then((r) => setHave(new Set(r.items.map((i) => i.source ?? ""))))
.catch(() => setHave(new Set()));
}, []);
const read = async () => {
setBusy(true);
setError(null);
setInfo(null);
try {
const r = await api.artifacts.inspectMarket(link.trim());
setInfo(r);
setPicked(
new Set(
r.plugins
.filter((p) => !have.has(p.key))
.slice(0, 1)
.map((p) => p.key)
)
);
setMarket(r.market);
} catch (err) {
const e = err as Error & { code?: string };
setError(e.message);
if (MANUAL_CODES.has(e.code ?? "")) {
setManual(true);
setMarket(link.trim());
}
} finally {
setBusy(false);
}
};
const addPicked = async () => {
if (!info) return;
setBusy(true);
let n = 0;
try {
for (const p of info.plugins.filter((x) => picked.has(x.key))) {
await api.artifacts.addPlugin({
key: p.key,
market: info.market,
title: p.name,
description: p.description,
});
n++;
}
toast.success(t("plugin.addedMany", { count: n }));
onAdded();
onClose();
} catch (err) {
toast.error(err);
if (n) onAdded();
} finally {
setBusy(false);
}
};
const toggle = (k: string) =>
setPicked((s) => {
const next = new Set(s);
if (next.has(k)) next.delete(k);
else next.add(k);
return next;
});
return (
<div className="space-y-3">
<form
className="space-y-1"
onSubmit={(e) => {
e.preventDefault();
if (link.trim()) void read();
}}
>
<label className={label}>
{t("plugin.link")}
<div className="mt-0.5 flex gap-2">
<input
className={field}
value={link}
autoFocus
placeholder="https://git.example.com/org/claude-skills.git"
onChange={(e) => setLink(e.target.value)}
/>
<button
type="submit"
className="btn-primary shrink-0 text-sm"
disabled={busy || !link.trim()}
>
{busy && !info ? t("plugin.reading") : t("plugin.read")}
</button>
</div>
</label>
<p className={hint}>{t("plugin.linkHint")}</p>
</form>
{error && (
<p
role="alert"
className="rounded border border-status-danger/40 px-2 py-1 text-xs text-status-danger"
>
{error}
</p>
)}
{info && (
<div className="space-y-2" data-testid="market-plugins">
<div>
<p className="text-sm font-medium text-fg-primary">
<Package className="mr-1 inline h-4 w-4 text-fg-muted" />
{info.name}
</p>
{info.description && <p className={hint}>{info.description}</p>}
</div>
{info.plugins.length === 0 ? (
<p className={hint}>{t("plugin.none")}</p>
) : (
<>
<label className="flex items-center gap-2 text-xs text-fg-secondary">
<input
type="checkbox"
checked={picked.size === info.plugins.length}
onChange={(e) =>
setPicked(new Set(e.target.checked ? info.plugins.map((p) => p.key) : []))
}
/>
{t("plugin.all")}
</label>
<ul className="max-h-64 space-y-1 overflow-auto">
{info.plugins.map((p) => (
<li key={p.key}>
<label className="flex cursor-pointer items-start gap-2 rounded px-1 py-1 hover:bg-surface-2">
<input
type="checkbox"
className="mt-0.5"
checked={picked.has(p.key)}
onChange={() => toggle(p.key)}
/>
<span className="min-w-0 flex-1">
<span className="text-xs font-medium text-fg-primary">{p.name}</span>
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">{p.key}</span>
{p.version && (
<span className="ml-1.5 text-[10px] text-fg-muted">v{p.version}</span>
)}
{have.has(p.key) && (
<span className="ml-1.5 rounded bg-surface-3 px-1 text-[10px] text-fg-secondary">
{t("plugin.have")}
</span>
)}
{p.description && (
<span className="block text-[11px] text-fg-muted">{p.description}</span>
)}
</span>
</label>
</li>
))}
</ul>
<div className="flex justify-end">
<button
type="button"
className="btn-primary text-sm"
disabled={busy || picked.size === 0}
onClick={() => void addPicked()}
>
{t("plugin.addPicked", { count: picked.size })}
</button>
</div>
</>
)}
</div>
)}
<div className="border-t border-border-light pt-2">
<button
type="button"
className="text-xs text-accent hover:underline"
aria-expanded={manual}
onClick={() => setManual((v) => !v)}
>
{t("plugin.manual")}
</button>
{manual && (
<form
className="mt-2 space-y-2"
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
try {
await api.artifacts.addPlugin({
key: key.trim(),
market: market.trim(),
title,
description,
});
toast.success(t("added"));
onAdded();
onClose();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
}}
>
<input
className={field}
placeholder="my-plugin@my-marketplace"
aria-label={t("form.pluginKey")}
value={key}
onChange={(e) => setKey(e.target.value)}
/>
<input
className={field}
placeholder="owner/repo · https://… · git@host:org/repo.git"
aria-label={t("form.market")}
value={market}
onChange={(e) => setMarket(e.target.value)}
/>
<input
className={field}
placeholder={t("form.title")}
aria-label={t("form.title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className={field}
rows={2}
placeholder={t("form.description")}
aria-label={t("form.description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={busy || !key.trim() || !market.trim()}
>
{t("form.save")}
</button>
</div>
</form>
)}
</div>
</div>
);
}
// ── File upload ───────────────────────────────────────────────────────────
export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
const { t } = useTranslation("artifacts");
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [busy, setBusy] = useState(false);
const tooBig = !!file && file.size > 25 * 1024 * 1024;
return (
<form
className="space-y-2"
onSubmit={async (e) => {
e.preventDefault();
if (!file) return;
setBusy(true);
try {
await api.artifacts.uploadFile(file, { title: title.trim() || undefined });
toast.success(t("added"));
setFile(null);
setTitle("");
onAdded();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
}}
>
<label className={label}>
{t("file.fromComputer")}
<input
type="file"
data-testid="file-upload"
className="mt-0.5 block w-full text-xs text-fg-secondary"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
{tooBig && <p className="text-xs text-status-danger">{t("file.tooBig")}</p>}
<div className="flex gap-2">
<input
className={field}
placeholder={t("form.title")}
aria-label={t("form.title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<button
type="submit"
className="btn-primary shrink-0 text-sm"
disabled={busy || !file || tooBig}
>
<Upload className="h-3.5 w-3.5" /> {t("file.upload")}
</button>
</div>
</form>
);
}
+73 -3
View File
@@ -25,7 +25,10 @@
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
* of another tab, a tab (or a pane header) joins that tab instead
* (`joinGroup`: side by side, then under the right pane, then under the left
* one, up to four panes). A right-click on a tab
* one, up to four panes). A pane drag held over another tab for `SPRING_MS` shows that tab
* (spring-loaded, as a file manager's folder), so the pane can be dropped on
* any of its panes' edges; near either end of an overflowing bar a drag
* scrolls it. A right-click on a tab
* opens the same actions as a menu (`ContextMenu`). With tabs from every
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
* and `workspaceOf` names each tab's workspace before its own name. Settings
@@ -57,7 +60,14 @@ import { isSubmitEnter } from "../../lib/keys";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
import {
PANE_DRAG_TYPE,
RunIcon,
paneDragSlot,
setDragChip,
startPaneDrag,
type GroupsUpdate,
} from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import {
type PaneGroups,
@@ -155,6 +165,11 @@ interface Tab {
const URGENCY: RunState[] = ["awaiting", "error", "running"];
/** How long a pane drag rests on a tab before that tab is shown. */
export const SPRING_MS = 600;
/** Within this many px of an overflowing bar's end, a drag scrolls it. */
const EDGE_SCROLL_PX = 40;
/** The Settings tab's key among the tabs' (a group's key is its slots). */
const SETTINGS_KEY = "settings";
@@ -256,8 +271,59 @@ export function ConsoleTabs({
if (!tab || dragged.current.some((s) => tab.slots.includes(s))) return null;
return tab.slots.length + Math.max(dragged.current.length, 1) <= MAX_PANES ? i : null;
};
// A pane drag resting on a tab (its drawn index) shows it after SPRING_MS.
const spring = useRef<{ at: number; timer: ReturnType<typeof setTimeout> } | null>(null);
const stopSpring = () => {
if (spring.current) clearTimeout(spring.current.timer);
spring.current = null;
};
useEffect(() => stopSpring, []);
const hoveredIndexAt = (x: number) => {
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
return els.findIndex((el) => {
const b = el.getBoundingClientRect();
return x >= b.left && x <= b.right;
});
};
const springAt = (e: DragEvent) => {
const slot = paneDragSlot();
const i = hoveredIndexAt(e.clientX);
const tab = tabs[i];
// Only a pane drag, onto a tab not on screen that does not hold it.
const target =
slot !== null &&
tab &&
!settings?.active &&
tab.index !== groups.active &&
!tab.slots.includes(slot)
? i
: null;
if ((spring.current?.at ?? null) === target) return;
stopSpring();
if (target === null || !tab) return;
const index = tab.index;
spring.current = {
at: target,
timer: setTimeout(() => {
spring.current = null;
if (paneDragSlot() === null) return;
onGroupsChange((g) =>
g.active === index || !g.groups[index] ? g : { ...g, active: index }
);
}, SPRING_MS),
};
};
// An overflowing bar has no scrollbar: near either end, a drag scrolls it.
const edgeScroll = (x: number) => {
const list = listRef.current;
if (!list || list.scrollWidth <= list.clientWidth) return;
const b = list.getBoundingClientRect();
if (x < b.left + EDGE_SCROLL_PX) list.scrollLeft -= 16;
else if (x > b.right - EDGE_SCROLL_PX) list.scrollLeft += 16;
};
const endDrag = () => {
dragged.current = [];
stopSpring();
setDropAt(null);
setJoinAt(null);
};
@@ -266,6 +332,8 @@ export function ConsoleTabs({
if (!isBarDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
edgeScroll(e.clientX);
springAt(e);
const join = joinIndexAt(e.clientX);
const at = join === null ? dropIndexAt(e.clientX) : null;
setJoinAt((d) => (d === join ? d : join));
@@ -273,6 +341,7 @@ export function ConsoleTabs({
},
onDragLeave: (e: DragEvent) => {
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
stopSpring();
setDropAt(null);
setJoinAt(null);
},
@@ -504,10 +573,11 @@ export function ConsoleTabs({
draggable
onDragStart={(e) => {
dragged.current = tab.slots;
if (single) startPaneDrag(e, single.slot ?? 0);
if (single) startPaneDrag(e, single.slot ?? 0, label(single));
else {
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
setDragChip(e, tabLabel(tab));
}
}}
onContextMenu={(e) => tabCtx.open(e, tab)}
+62 -9
View File
@@ -14,7 +14,13 @@
* one tab's panes; `onChange` applies through `updateActive`, so a session
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
* shows its state dot, as its tab does.
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). While a
* drag is on, the pane it carries is dimmed, the browser's ghost is a small
* chip with its name (`setDragChip`) instead of a whole header, and the drop
* preview names what the drop will do (`dropAction`). The dragged pane's
* header stays mounted even once the tab bar has switched tabs under the
* drag (`ConsoleTabs` spring-loads a hovered tab) — removing the drag's
* source node would lose its `dragend`. A
* right-click on a header offers the swap, a split with a new terminal and
* the close as one menu. Dividers resize by pointer or by arrow keys; a drag
* moves only the line and commits the ratio on release, so the layout is
@@ -69,11 +75,31 @@ export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
let dragSlot: number | null = null;
/** Starts dragging a pane or a tab (`slot`) onto the grid. */
export function startPaneDrag(e: React.DragEvent, slot: number): void {
/** The slot a pane drag carries, or null (no pane drag is on). */
export const paneDragSlot = (): number | null => dragSlot;
/** The drag's ghost: a small chip naming what moves, not a snapshot of a
* whole pane header or tab. Drawn off screen for the browser to copy. */
export function setDragChip(e: React.DragEvent, label: string): void {
const dt = e.dataTransfer;
if (!label || typeof dt?.setDragImage !== "function") return;
const chip = document.createElement("div");
chip.textContent = label;
chip.className =
"fixed -top-96 left-0 max-w-[16rem] truncate rounded-md border border-accent/60 bg-surface-2 px-2.5 py-1 text-xs font-medium text-fg-primary shadow-lg";
document.body.appendChild(chip);
dt.setDragImage(chip, 12, 12);
// The browser copies it during dragstart; it may go right after.
window.setTimeout(() => chip.remove(), 0);
}
/** Starts dragging a pane or a tab (`slot`) onto the grid; `label` names it
* on the drag's chip. */
export function startPaneDrag(e: React.DragEvent, slot: number, label?: string): void {
dragSlot = slot;
e.dataTransfer?.setData(PANE_DRAG_TYPE, String(slot));
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
if (label) setDragChip(e, label);
}
/** A run's icon: a shell's terminal glyph, else its AI tool's mark. */
@@ -140,6 +166,14 @@ export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone
return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null;
}
/** What a drop does, as an i18n key (`lanes` namespace): the center swaps a
* pane already shown, or shows one from another tab in the target's place
* (the target gets a tab of its own); an edge places it on that side. */
export function dropAction(l: PaneLayout, src: number, zone: Zone): string {
if (zone !== "center") return `panes.drop.${zone}`;
return leaves(l.root).includes(src) ? "panes.drop.swap" : "panes.drop.replace";
}
/** A small anchored menu: a trigger button and a list that closes on a pick,
* an outside click or Escape. */
function PaneMenu({
@@ -398,6 +432,8 @@ export function PaneGrid({
const { t } = useTranslation("lanes");
const gridRef = useRef<HTMLDivElement>(null);
const [drop, setDrop] = useState<{ src: number; slot: number; zone: Zone } | null>(null);
// The slot a pane drag carries (a header here, or a tab from the bar).
const [dragging, setDragging] = useState<number | null>(null);
const { panes, dividers } = layoutRects(layout.root);
const shown = leaves(layout.root);
const rectOf = new Map(panes.map((p) => [p.slot, p]));
@@ -416,6 +452,7 @@ export function PaneGrid({
const end = () => {
dragSlot = null;
setDrop(null);
setDragging(null);
};
window.addEventListener("dragend", end);
window.addEventListener("drop", end);
@@ -479,6 +516,7 @@ export function PaneGrid({
if (!isPaneDrag(e)) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
if (dragSlot !== dragging) setDragging(dragSlot);
const z = zoneAt(e);
const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null;
setDrop((d) =>
@@ -495,6 +533,7 @@ export function PaneGrid({
const z = zoneAt(e);
dragSlot = null;
setDrop(null);
setDragging(null);
if (!z || !Number.isInteger(src) || src === z.slot) return;
onChange((l) => applyDrop(l, src, z.slot, z.zone));
}}
@@ -515,6 +554,7 @@ export function PaneGrid({
const r = rectOf.get(slot);
const run = runOf(slot);
const focused = layout.focus === slot;
const moving = dragging === slot;
return (
<div
key={key}
@@ -522,17 +562,23 @@ export function PaneGrid({
data-focused={r && focused ? "true" : undefined}
// A menu opened in this pane stays inside it (`menuRoom`).
data-menu-bounds
className={r ? "absolute flex flex-col" : "hidden"}
data-dragging={moving ? "true" : undefined}
className={r ? `absolute flex flex-col${moving ? " opacity-40" : ""}` : "hidden"}
style={r ? pct(r) : undefined}
onPointerDownCapture={() => r && focus(slot)}
// Tabbing into a pane focuses it as a click does.
onFocusCapture={() => r && focus(slot)}
>
{multi && r && (
{/* The dragged pane's header stays while the drag lasts, even
hidden in another tab: it is the drag's source node. */}
{((multi && r) || moving) && (
<div
data-testid={`pane-header-${slot}`}
draggable
onDragStart={(e) => startPaneDrag(e, slot)}
onDragStart={(e) => {
startPaneDrag(e, slot, run ? label(run) : t("panes.empty"));
setDragging(slot);
}}
onContextMenu={(e) => paneCtx.open(e, slot)}
title={t("panes.drag")}
className={`relative flex h-8 shrink-0 cursor-grab items-center gap-1 border-b border-border bg-surface-1 pl-1.5 pr-1 text-xs ${
@@ -667,12 +713,19 @@ export function PaneGrid({
}
/>
))}
{preview && (
{preview && drop && (
<div
data-testid="pane-drop-preview"
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
className="pointer-events-none absolute z-20 flex items-center justify-center rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
style={pct(preview)}
/>
>
<span
data-testid="pane-drop-label"
className="max-w-[90%] truncate rounded-md bg-accent-solid px-2.5 py-1 text-xs font-medium text-white shadow-lg"
>
{t(dropAction(layout, drop.src, drop.zone))}
</span>
</div>
)}
</div>
);
@@ -0,0 +1,126 @@
/**
* @file ConsoleTabs.test.tsx
* @description The tab bar's spring-loaded tabs: a pane drag resting on
* another tab shows it after `SPRING_MS`, so the pane can be dropped on that
* tab's panes; moving off before then, or resting on the tab already holding
* the pane, shows nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, createEvent, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import type { RunHandle } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
import { pane, split, type PaneGroups } from "../../../lib/paneLayout";
import { ConsoleTabs, SPRING_MS } from "../ConsoleTabs";
import { PANE_DRAG_TYPE, paneDragSlot } from "../PaneGrid";
vi.mock("../../../lib/api", () => ({
api: {
aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
},
},
}));
const LANE = { id: 1, tool: "claude", cwd: "/w", title: "w" } as unknown as Lane;
const run = (slot: number): RunHandle => ({
id: slot ? `agenthub-lane-1-${slot}` : "agenthub-lane-1",
laneId: 1,
slot,
status: "running",
cwd: "/w",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
tool: "shell",
});
// Tab 0: pane 0 alone (on screen); tab 1: panes 1 and 2.
const GROUPS: PaneGroups = {
groups: [
{ root: pane(0), focus: 0 },
{ root: split("row", pane(1), pane(2)), focus: 1 },
],
active: 0,
};
function setup() {
let groups = GROUPS;
const onGroupsChange = vi.fn((fn: (g: PaneGroups) => PaneGroups) => {
groups = fn(groups);
});
render(
<MemoryRouter>
<ConsoleTabs
lane={LANE}
tabs={[run(0), run(1), run(2)]}
groups={GROUPS}
onGroupsChange={onGroupsChange}
onOpen={vi.fn()}
onClosed={vi.fn()}
/>
</MemoryRouter>
);
// jsdom lays nothing out: tab i spans x 100i … 100i+100.
for (const el of document.querySelectorAll<HTMLElement>("[data-tab-index]")) {
const i = Number(el.dataset.tabIndex);
el.getBoundingClientRect = () =>
({
left: i * 100,
right: i * 100 + 100,
width: 100,
top: 0,
bottom: 40,
height: 40,
}) as DOMRect;
}
return { onGroupsChange, groups: () => groups };
}
const dataTransfer = () => ({
types: [PANE_DRAG_TYPE],
setData: vi.fn(),
getData: () => "",
effectAllowed: "",
dropEffect: "",
});
function drag(type: "dragStart" | "dragOver", el: Element, clientX = 0) {
const ev = createEvent[type](el, { dataTransfer: dataTransfer() });
Object.defineProperty(ev, "clientX", { value: clientX });
fireEvent(el, ev);
}
const tabEl = (key: string) => screen.getByTestId(`console-tab-${key}`).parentElement as Element;
describe("ConsoleTabs spring-loaded tabs", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("a pane drag resting on another tab shows that tab", () => {
const { onGroupsChange, groups } = setup();
drag("dragStart", tabEl("0"));
expect(paneDragSlot()).toBe(0);
drag("dragOver", tabEl("1-2"), 150);
act(() => void vi.advanceTimersByTime(SPRING_MS - 1));
expect(onGroupsChange).not.toHaveBeenCalled();
act(() => void vi.advanceTimersByTime(1));
expect(groups().active).toBe(1);
});
it("moving off the tab first, or resting on the pane's own tab, shows nothing", () => {
const { onGroupsChange } = setup();
drag("dragStart", tabEl("0"));
drag("dragOver", tabEl("1-2"), 150);
act(() => void vi.advanceTimersByTime(SPRING_MS / 2));
drag("dragOver", tabEl("0"), 50);
act(() => void vi.advanceTimersByTime(SPRING_MS * 2));
expect(onGroupsChange).not.toHaveBeenCalled();
});
});
@@ -2,12 +2,13 @@
* @file PaneGrid.test.ts
* @description The pane drop preview: it draws where the dragged pane will
* actually land (`dropPreview` over `applyDrop`), not half of the target's
* current box, and nothing for a drop that changes nothing.
* current box, and nothing for a drop that changes nothing; and the label
* naming what a drop does (`dropAction`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { dropPreview } from "../PaneGrid";
import { dropAction, dropPreview } from "../PaneGrid";
import { pane, split, type PaneLayout } from "../../../lib/paneLayout";
const AB: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
@@ -34,3 +35,15 @@ describe("dropPreview", () => {
expect(dropPreview(full, 5, 0, "left")).toBeNull();
});
});
describe("dropAction", () => {
it("an edge places the pane on that side", () => {
expect(dropAction(AB, 0, "left")).toBe("panes.drop.left");
expect(dropAction(AB, 2, "bottom")).toBe("panes.drop.bottom");
});
it("the center swaps a shown pane, or shows one from another tab in its place", () => {
expect(dropAction(AB, 0, "center")).toBe("panes.drop.swap");
expect(dropAction(AB, 2, "center")).toBe("panes.drop.replace");
});
});
@@ -2,7 +2,9 @@
* @file AccountSettings.tsx
* @description Settings → Account (server mode, any signed-in role): the
* account's own information (`GET /api/me/account` — username, role, member
* since, workspaces, sessions, active sign-ins, last sign-in), its display name
* since, workspaces, sessions, active sign-ins, last sign-in), its avatar
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB; the
* sidebar shows it at once), its display name
* and email (`PATCH /api/me/account`), a password change that asks for the
* current one (`POST /api/me/password` — this browser stays signed in, every
* other sign-in ends) and "Sign out other devices" (`POST
@@ -11,9 +13,17 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { LogOut, RefreshCw } from "lucide-react";
import { Camera, LogOut, RefreshCw, Trash2 } from "lucide-react";
import { api, type MyAccount } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
@@ -21,6 +31,7 @@ import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { useAuth } from "../AuthGate";
import { ROLE_BADGE } from "../users/UserEditDialog";
import { UserAvatar } from "../users/UserAvatar";
import { SettingsSection } from "./SettingsRow";
type ServerAccount = Extract<MyAccount, { mode: "server" }>;
@@ -42,8 +53,12 @@ export function AccountSettings() {
(r: MyAccount) => {
if (r.mode !== "server") return;
setAccount(r);
// The sidebar shows the display name: let it follow a save at once.
updateUser({ displayName: r.user.displayName, email: r.user.email });
// The sidebar shows the name and avatar: let them follow a save at once.
updateUser({
displayName: r.user.displayName,
email: r.user.email,
avatarAt: r.user.avatarAt ?? null,
});
},
[updateUser]
);
@@ -72,7 +87,7 @@ export function AccountSettings() {
return (
<div className="space-y-6">
<ProfileCard account={account} />
<ProfileCard account={account} onSaved={apply} />
<ProfileForm account={account} onSaved={apply} />
<PasswordForm />
<SignOutOthers logins={account.logins} onDone={load} />
@@ -80,19 +95,84 @@ export function AccountSettings() {
);
}
function ProfileCard({ account }: { account: ServerAccount }) {
/** The largest avatar the server takes (server/lib/avatars.js). */
const MAX_AVATAR_BYTES = 1024 * 1024;
function ProfileCard({
account,
onSaved,
}: {
account: ServerAccount;
onSaved: (r: MyAccount) => void;
}) {
const { t } = useTranslation("settings");
const { user } = account;
const shown = user.displayName || user.username;
const fileRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const run = async (op: () => Promise<MyAccount>, done: string) => {
setBusy(true);
try {
onSaved(await op());
toast.success(done);
} catch (e) {
toast.error(errText(t, e));
} finally {
setBusy(false);
}
};
const pick = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ""; // the same file can be picked again
if (!file) return;
if (file.size > MAX_AVATAR_BYTES) {
toast.error(t("account.avatar.tooBig"));
return;
}
void run(() => api.me.setAvatar(file), t("account.avatar.saved"));
};
return (
<section className="card p-5" data-testid="account-card">
<div className="flex items-center gap-4">
<span
aria-hidden
className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-lg font-semibold uppercase text-accent"
>
{shown.slice(0, 2)}
</span>
<div className="flex flex-shrink-0 flex-col items-center gap-1.5">
<UserAvatar name={shown} avatarAt={user.avatarAt} className="h-14 w-14 text-lg" />
<div className="flex items-center gap-0.5">
<button
type="button"
className="btn-ghost h-6 w-6 justify-center p-0"
onClick={() => fileRef.current?.click()}
disabled={busy}
title={t("account.avatar.change")}
aria-label={t("account.avatar.change")}
data-testid="account-avatar-change"
>
<Camera className="h-3.5 w-3.5" />
</button>
{user.avatarAt && (
<button
type="button"
className="btn-ghost h-6 w-6 justify-center p-0"
onClick={() => void run(() => api.me.removeAvatar(), t("account.avatar.removed"))}
disabled={busy}
title={t("account.avatar.remove")}
aria-label={t("account.avatar.remove")}
data-testid="account-avatar-remove"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
className="hidden"
onChange={pick}
data-testid="account-avatar-input"
/>
</div>
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-fg-primary">{shown}</h3>
<p className="truncate text-xs text-fg-muted">
@@ -0,0 +1,45 @@
/**
* @file ArtifactsHubSettings — Settings → AI tools → Artifacts Hub: the
* admin's server-wide switch that allows or blocks installing a shared skill
* or plugin from the Hub into a user's own account (`GET/PUT
* /api/artifacts/settings`). Renders nothing in local mode (there is no Hub)
* or for anyone the server does not let manage it (`canManage`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type ArtifactSettings } from "../../lib/api";
import { toast } from "../../lib/toast";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function ArtifactsHubSettings() {
const { t } = useTranslation("artifacts");
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
useEffect(() => {
api.artifacts
.settings()
.then(setSettings)
.catch(() => setSettings(null));
}, []);
if (!settings?.serverMode || !settings.canManage) return null;
return (
<SettingsSection id="artifacts-hub" title={t("title")}>
<SettingsSwitchRow
label={t("installsSwitch")}
description={t("installsSwitchHint")}
checked={settings.installsEnabled}
onChange={async (v) => {
try {
const r = await api.artifacts.setInstallsEnabled(v);
setSettings({ ...settings, installsEnabled: r.installsEnabled });
} catch (err) {
toast.error(err);
}
}}
/>
</SettingsSection>
);
}
@@ -2,8 +2,8 @@
* @file AccountSettings.test.tsx
* @description Settings → Account: shows the account's own information, saves
* only a changed display name/email, refuses a password change whose two new
* passwords differ, sends the current + new password, and asks before signing
* the other devices out.
* passwords differ, sends the current + new password, asks before signing
* the other devices out, and uploads/removes the avatar.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,6 +16,9 @@ const me = vi.hoisted(() => ({
updateAccount: vi.fn(),
changePassword: vi.fn(),
signOutOthers: vi.fn(),
setAvatar: vi.fn(),
removeAvatar: vi.fn(),
avatarUrl: (at?: string | null) => (at ? `/api/me/avatar?v=${at}` : null),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -44,7 +47,7 @@ const account = {
};
beforeEach(() => {
for (const fn of Object.values(me)) fn.mockReset();
for (const fn of Object.values(me)) if ("mockReset" in fn) fn.mockReset();
me.account.mockResolvedValue(account);
me.updateAccount.mockResolvedValue(account);
me.changePassword.mockResolvedValue({ ok: true });
@@ -53,6 +56,26 @@ beforeEach(() => {
});
describe("AccountSettings", () => {
it("uploads a picked avatar and removes it again", async () => {
const ue = userEvent.setup();
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-14T00:00:00Z" } };
me.setAvatar.mockResolvedValue(withAvatar);
me.removeAvatar.mockResolvedValue(account);
render(<AccountSettings />);
await screen.findByTestId("account-card");
expect(screen.getByTestId("user-avatar-initials").textContent).toBe("Mi");
expect(screen.queryByTestId("account-avatar-remove")).toBeNull();
const file = new File([new Uint8Array([0x89, 0x50])], "me.png", { type: "image/png" });
await ue.upload(screen.getByTestId("account-avatar-input"), file);
await waitFor(() => expect(me.setAvatar).toHaveBeenCalledWith(file));
expect((await screen.findByTestId("user-avatar-img")).getAttribute("src")).toBe(
"/api/me/avatar?v=2026-10-14T00:00:00Z"
);
await ue.click(screen.getByTestId("account-avatar-remove"));
await waitFor(() => expect(me.removeAvatar).toHaveBeenCalled());
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
});
it("shows the account's own information", async () => {
render(<AccountSettings />);
const card = await screen.findByTestId("account-card");
@@ -0,0 +1,46 @@
/**
* @file Tests for ArtifactsHubSettings — the admin's Hub install switch in
* Settings → AI tools: hidden in local mode and for a non-manager, and a
* toggle saves through PUT /api/artifacts/settings.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import "../../../i18n";
import { ArtifactsHubSettings } from "../ArtifactsHubSettings";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: { artifacts: { settings: vi.fn(), setInstallsEnabled: vi.fn() } },
}));
const settings = vi.mocked(api.artifacts.settings);
const setInstalls = vi.mocked(api.artifacts.setInstallsEnabled);
const base = { serverMode: true, canShare: true, installsEnabled: true, canManage: true };
describe("ArtifactsHubSettings", () => {
beforeEach(() => vi.clearAllMocks());
it("lets an admin turn Hub installs off", async () => {
settings.mockResolvedValue(base);
setInstalls.mockResolvedValue({ installsEnabled: false });
render(<ArtifactsHubSettings />);
const sw = await screen.findByRole("switch", { name: "Allow installing from the Hub" });
fireEvent.click(sw);
await waitFor(() => expect(setInstalls).toHaveBeenCalledWith(false));
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "false"));
});
it("renders nothing for a non-manager or in local mode", async () => {
settings.mockResolvedValue({ ...base, canManage: false });
const { container, unmount } = render(<ArtifactsHubSettings />);
await waitFor(() => expect(settings).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
unmount();
settings.mockResolvedValue({ ...base, serverMode: false });
const r = render(<ArtifactsHubSettings />);
await waitFor(() => expect(settings).toHaveBeenCalledTimes(2));
expect(r.container).toBeEmptyDOMElement();
});
});
@@ -2,7 +2,7 @@
* @file AccountPoolCard.tsx
* @description The Users page's account pool card (`GET /api/accounts`): the
* Claude and Codex accounts every user's new sessions are spread over, each
* with its live panes, $ in the last 5 h / 7 d and quota — so nobody signs in
* with its live panes, $ and tokens in the last 5 h / 7 d and quota — so nobody signs in
* per user. An admin adds one — for Claude a login MOVED in from a saved
* switch-user profile (`GET /api/accounts/profiles`), a pasted
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
@@ -23,7 +23,7 @@ import {
type PoolProfile,
type PoolTool,
} from "../../lib/api";
import { fmtCost, formatDateTime } from "../../lib/format";
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin";
@@ -40,6 +40,8 @@ type Row = {
livePanes: number;
spend5h: number;
spend7d: number;
tokens5h: number;
tokens7d: number;
quotaPercent: number | null;
full: boolean;
};
@@ -175,9 +177,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
{r.quotaPercent == null ? "—" : `${Math.round(r.quotaPercent)}%`}
</td>
<td className="px-5 py-2.5 text-right tabular-nums">
<div className="text-sm text-fg-primary">{fmtCost(r.spend5h)}</div>
<div className="text-sm text-fg-primary">
{fmtCost(r.spend5h)}
<span className="ml-1.5 text-[11px] text-fg-muted">
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
</span>
</div>
<div className="text-[11px] text-fg-muted">
{t("pool.spend7d", { cost: fmtCost(r.spend7d) })}
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
</div>
</td>
{isAdmin && (
@@ -0,0 +1,59 @@
/**
* @file UserAvatar.tsx
* @description An account's round picture: the signed-in account's own
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
* or when the image fails to load — the first letters of its name. Used by the
* sidebar's account row, its workspace owner groups and Settings → Account.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { api } from "../../lib/api";
export function UserAvatar({
name,
avatarAt,
userId,
letters = 2,
className = "h-7 w-7 text-[11px]",
initialsClassName = "bg-accent/15 text-accent",
}: {
name: string;
avatarAt?: string | null;
/** Another account's (staff only); omitted = the signed-in account's own. */
userId?: number;
/** How many letters of the name stand in for a missing picture. */
letters?: number;
/** Size and font size: the parts that differ between call sites. */
className?: string;
/** The initials' colours. */
initialsClassName?: string;
}) {
const url = userId != null ? api.users.avatarUrl(userId, avatarAt) : api.me.avatarUrl(avatarAt);
// A broken image falls back to the initials, per URL.
const [failed, setFailed] = useState<string | null>(null);
const base = `flex flex-shrink-0 items-center justify-center overflow-hidden rounded-full ${className}`;
if (url && failed !== url) {
return (
<img
src={url}
alt=""
aria-hidden
draggable={false}
onError={() => setFailed(url)}
className={`${base} object-cover`}
data-testid="user-avatar-img"
/>
);
}
return (
<span
aria-hidden
className={`${base} ${initialsClassName} font-semibold uppercase`}
data-testid="user-avatar-initials"
>
{name.slice(0, letters)}
</span>
);
}
@@ -40,6 +40,8 @@ const POOL: AccountPool = {
livePanes: 2,
spend5h: 1.5,
spend7d: 12,
tokens5h: 250_000,
tokens7d: 3_400_000,
quotaPercent: 42,
full: false,
},
@@ -52,6 +54,8 @@ const POOL: AccountPool = {
livePanes: 0,
spend5h: 0,
spend7d: 0,
tokens5h: 0,
tokens7d: 0,
quotaPercent: null,
full: false,
},
@@ -82,6 +86,8 @@ describe("AccountPoolCard", () => {
expect(await screen.findByText("team-1")).toBeInTheDocument();
expect(screen.getByText("cx-1")).toBeInTheDocument();
expect(screen.getByText("42%")).toBeInTheDocument();
expect(screen.getByText("250.0K tokens")).toBeInTheDocument();
expect(screen.getByText("$12.00 · 3.4M tokens in 7 days")).toBeInTheDocument();
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /add account/i })).toBeNull();
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
+39 -2
View File
@@ -35,7 +35,7 @@
"installs": "{{count}} installs",
"installed": "Installed to {{path}}",
"installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it",
"rename": "Rename",
"rename": "Edit",
"delete": "Delete",
"deleteTitle": "Delete “{{title}}”?",
"deleteBody": "It is removed from your library and the Hub. Anything already installed from it stays installed.",
@@ -71,5 +71,42 @@
"files": "{{count}} files",
"close": "Close",
"fromSession": "From a session",
"form.saveEdit": "Save"
"form.saveEdit": "Save",
"skill.tab.upload": "Upload files",
"skill.tab.write": "Write new",
"skill.tab.existing": "From my skills",
"skill.name": "Skill name",
"skill.slugHint": "Installs as ~/.claude/skills/{{slug}}/ and runs as /{{slug}}",
"skill.slugBad": "Use letters, digits, '.', '_' or '-'",
"skill.description": "Description — when Claude should use this skill",
"skill.descriptionHint": "e.g. Use when writing release notes from the git log",
"skill.content": "Instructions (the body of SKILL.md)",
"skill.contentHint": "Write the steps Claude should follow. You can also paste a whole SKILL.md, frontmatter included.",
"skill.dropHint": "Drop a SKILL.md, any Markdown file, a skill folder, several files or a .zip/.skill here",
"skill.pickFiles": "Choose files",
"skill.pickFolder": "Choose folder",
"skill.clear": "Clear",
"skill.uploading": "Uploading…",
"skill.shape.none": "Nothing to upload",
"skill.shape.single-md": "This Markdown file becomes the skill's SKILL.md",
"skill.shape.archive": "An archive — it is unpacked; each folder with a SKILL.md becomes a skill",
"skill.shape.one": "One skill folder",
"skill.shape.many": "{{count}} skills — each is added on its own, named by its folder",
"skill.shape.no-skill-md": "No SKILL.md here — add one, or upload a single Markdown file",
"skill.manyHint": "Several skills: each keeps its own folder name and the description in its SKILL.md.",
"skill.addedMany": "Added {{count}} skills",
"plugin.link": "Marketplace link",
"plugin.linkHint": "A git repository with .claude-plugin/marketplace.json (https link, the forge's web link, or owner/repo for GitHub), or a link to a marketplace.json.",
"plugin.read": "Read plugins",
"plugin.reading": "Reading…",
"plugin.none": "This marketplace lists no plugins.",
"plugin.all": "Select all",
"plugin.have": "already added",
"plugin.addPicked": "Add {{count}} plugin(s)",
"plugin.addedMany": "Added {{count}} plugin(s)",
"plugin.manual": "Type name@marketplace by hand (ssh link, private repository…)",
"file.fromComputer": "From your computer (≤ 25 MB)",
"file.upload": "Upload",
"file.tooBig": "The file is larger than 25 MB.",
"file.fromWorkspace": "Or pin a document a workspace's sessions wrote"
}
+7 -1
View File
@@ -436,7 +436,13 @@
"tabs.settings": "Settings",
"tabs.closeSettings": "Close Settings",
"tabs.splitHint": "Ctrl/Cmd+click: open in a split · drag onto a pane to place it",
"panes.drag": "Drag to move this pane — onto another pane's edge to split there, onto its middle to swap",
"panes.drag": "Drag to move this pane — onto another pane's edge to split there, onto its middle to swap; hold it over another tab to open that tab",
"panes.drop.left": "Place on the left",
"panes.drop.right": "Place on the right",
"panes.drop.top": "Place on top",
"panes.drop.bottom": "Place below",
"panes.drop.swap": "Swap places",
"panes.drop.replace": "Show here — this pane moves to its own tab",
"panes.pick": "Show another session in this pane",
"panes.empty": "No session",
"panes.split.right": "Split right",
+7
View File
@@ -1267,6 +1267,13 @@
"appliesTo": "Applies to"
},
"account": {
"avatar": {
"change": "Change picture",
"remove": "Remove picture",
"saved": "Picture updated",
"removed": "Picture removed",
"tooBig": "The picture must be at most 1 MB"
},
"loadFailed": "Could not load your account: {{message}}",
"memberSince": "Member since",
"workspaces": "Workspaces",
+9 -1
View File
@@ -7,6 +7,11 @@
"sessions": "Sessions",
"last7d": "7 days",
"last30d": "30 days",
"storage": "Storage",
"storageHint": "{{files}} files · measured {{at}}",
"storagePartial": "at least — part of the folder could not be read in time",
"storageNone": "No home folder of their own",
"storageMeasuring": "Measuring…",
"disabled": "disabled",
"you": "you",
"tokens": "{{n}} tokens",
@@ -61,6 +66,8 @@
"deleted": "{{name}} was deleted."
},
"errors": {
"EBADIMAGE": "Only a PNG, JPEG, GIF or WebP picture",
"ETOOBIG": "The picture must be at most 1 MB",
"EREAUTH": "Your password is wrong.",
"ETOOMANY": "Too many wrong passwords. Try again in 15 minutes.",
"ESELF": "You cannot change your own role or disable yourself.",
@@ -128,7 +135,8 @@
"livePanes": "Live",
"quota": "Quota",
"spend": "5 hours",
"spend7d": "{{cost}} in 7 days",
"spend7d": "{{cost}} · {{tokens}} tokens in 7 days",
"tokens": "{{tokens}} tokens",
"added": "added {{at}}",
"expired": "expired",
"full": "full",
+39 -2
View File
@@ -35,7 +35,7 @@
"installs": "{{count}} lượt cài",
"installed": "Đã cài vào {{path}}",
"installedPlugin": "Đã cài {{name}} — mở phiên mới (hoặc /reload-plugins) để dùng",
"rename": "Đổi tên",
"rename": "Sửa",
"delete": "Xoá",
"deleteTitle": "Xoá “{{title}}”?",
"deleteBody": "Mục này bị gỡ khỏi thư viện của bạn và Hub. Những bản người khác đã cài vẫn giữ nguyên.",
@@ -71,5 +71,42 @@
"files": "{{count}} file",
"close": "Đóng",
"fromSession": "Từ một phiên",
"form.saveEdit": "Lưu"
"form.saveEdit": "Lưu",
"skill.tab.upload": "Tải file lên",
"skill.tab.write": "Viết mới",
"skill.tab.existing": "Từ skill có sẵn",
"skill.name": "Tên skill",
"skill.slugHint": "Cài vào ~/.claude/skills/{{slug}}/ và gọi bằng /{{slug}}",
"skill.slugBad": "Chỉ dùng chữ, số, '.', '_' hoặc '-'",
"skill.description": "Mô tả — khi nào Claude nên dùng skill này",
"skill.descriptionHint": "VD: Dùng khi viết release notes từ git log",
"skill.content": "Hướng dẫn (nội dung SKILL.md)",
"skill.contentHint": "Viết các bước Claude cần làm. Có thể dán nguyên một file SKILL.md, kể cả frontmatter.",
"skill.dropHint": "Kéo thả vào đây: SKILL.md, một file Markdown bất kỳ, thư mục skill, nhiều file hoặc file .zip/.skill",
"skill.pickFiles": "Chọn file",
"skill.pickFolder": "Chọn thư mục",
"skill.clear": "Bỏ chọn",
"skill.uploading": "Đang tải lên…",
"skill.shape.none": "Chưa có gì để tải lên",
"skill.shape.single-md": "File Markdown này sẽ thành SKILL.md của skill",
"skill.shape.archive": "File nén — sẽ được giải nén; mỗi thư mục có SKILL.md thành một skill",
"skill.shape.one": "Một thư mục skill",
"skill.shape.many": "{{count}} skill — mỗi skill được thêm riêng, đặt tên theo thư mục",
"skill.shape.no-skill-md": "Không có SKILL.md — thêm một file SKILL.md, hoặc chỉ tải lên một file Markdown",
"skill.manyHint": "Nhiều skill: mỗi skill giữ tên thư mục và mô tả trong SKILL.md của nó.",
"skill.addedMany": "Đã thêm {{count}} skill",
"plugin.link": "Link marketplace",
"plugin.linkHint": "Repo git có .claude-plugin/marketplace.json (link https, link trang web của repo, hoặc owner/repo với GitHub), hoặc link tới file marketplace.json.",
"plugin.read": "Đọc danh sách plugin",
"plugin.reading": "Đang đọc…",
"plugin.none": "Marketplace này không có plugin nào.",
"plugin.all": "Chọn tất cả",
"plugin.have": "đã thêm",
"plugin.addPicked": "Thêm {{count}} plugin",
"plugin.addedMany": "Đã thêm {{count}} plugin",
"plugin.manual": "Nhập tay name@marketplace (link ssh, repo riêng tư…)",
"file.fromComputer": "Từ máy tính (≤ 25 MB)",
"file.upload": "Tải lên",
"file.tooBig": "File lớn hơn 25 MB.",
"file.fromWorkspace": "Hoặc ghim tài liệu do các phiên của workspace đã ghi"
}
+7 -1
View File
@@ -436,7 +436,13 @@
"tabs.settings": "Cài đặt",
"tabs.closeSettings": "Đóng Cài đặt",
"tabs.splitHint": "Ctrl/Cmd+click: mở ở split · kéo thả vào một pane để đặt vị trí",
"panes.drag": "Kéo để di chuyển pane — thả vào cạnh pane khác để tách ở đó, vào giữa để đổi chỗ",
"panes.drag": "Kéo để di chuyển pane — thả vào cạnh pane khác để tách ở đó, vào giữa để đổi chỗ; giữ trên một tab khác để mở tab đó",
"panes.drop.left": "Đặt bên trái",
"panes.drop.right": "Đặt bên phải",
"panes.drop.top": "Đặt phía trên",
"panes.drop.bottom": "Đặt phía dưới",
"panes.drop.swap": "Đổi chỗ",
"panes.drop.replace": "Hiện ở đây — pane này sang tab riêng",
"panes.pick": "Hiện session khác trong pane này",
"panes.empty": "Không có session",
"panes.split.right": "Tách sang phải",
+7
View File
@@ -1267,6 +1267,13 @@
"appliesTo": "Áp dụng cho"
},
"account": {
"avatar": {
"change": "Đổi ảnh đại diện",
"remove": "Xoá ảnh đại diện",
"saved": "Đã cập nhật ảnh đại diện",
"removed": "Đã xoá ảnh đại diện",
"tooBig": "Ảnh tối đa 1 MB"
},
"loadFailed": "Không tải được tài khoản: {{message}}",
"memberSince": "Tham gia từ",
"workspaces": "Workspace",
+9 -1
View File
@@ -7,6 +7,11 @@
"sessions": "Phiên",
"last7d": "7 ngày",
"last30d": "30 ngày",
"storage": "Dung lượng",
"storageHint": "{{files}} tệp · đo lúc {{at}}",
"storagePartial": "ít nhất — một phần thư mục chưa đọc được kịp",
"storageNone": "Không có thư mục home riêng",
"storageMeasuring": "Đang đo…",
"disabled": "đã khoá",
"you": "bạn",
"tokens": "{{n}} token",
@@ -61,6 +66,8 @@
"deleted": "Đã xoá {{name}}."
},
"errors": {
"EBADIMAGE": "Chỉ nhận ảnh PNG, JPEG, GIF hoặc WebP",
"ETOOBIG": "Ảnh tối đa 1 MB",
"EREAUTH": "Mật khẩu của bạn không đúng.",
"ETOOMANY": "Nhập sai mật khẩu quá nhiều lần. Thử lại sau 15 phút.",
"ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.",
@@ -128,7 +135,8 @@
"livePanes": "Đang chạy",
"quota": "Quota",
"spend": "5 giờ",
"spend7d": "{{cost}} trong 7 ngày",
"spend7d": "{{cost}} · {{tokens}} tokens trong 7 ngày",
"tokens": "{{tokens}} tokens",
"added": "thêm lúc {{at}}",
"expired": "hết hạn",
"full": "đầy",
+117 -5
View File
@@ -544,6 +544,14 @@ export type ServerUserUsage = AuthUser & {
logins?: number;
lastLoginAt?: string | null;
};
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
export type UserStorage = {
bytes: number;
files: number;
/** The walk timed out or met an unreadable folder: a lower bound. */
partial: boolean;
measuredAt: string;
} | null;
/** GET /api/users/:id — one account plus the workspaces it owns. */
export type UserDetail = {
user: AuthUser & { disabled: boolean; logins?: number; lastLoginAt?: string | null };
@@ -585,6 +593,8 @@ export type PoolAccount = {
livePanes: number;
spend5h: number;
spend7d: number;
tokens5h: number;
tokens7d: number;
quotaPercent: number | null;
full: boolean;
};
@@ -596,6 +606,8 @@ export type CodexPoolAccount = {
livePanes: number;
spend5h: number;
spend7d: number;
tokens5h: number;
tokens7d: number;
quotaPercent: number | null;
full: boolean;
};
@@ -644,6 +656,8 @@ export type AuthUser = {
/** Optional profile fields — absent from an older server's answers. */
displayName?: string | null;
email?: string | null;
/** When the account's avatar was last set (null = none) — versions its URL. */
avatarAt?: string | null;
createdAt?: string;
};
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
@@ -679,6 +693,10 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
...(token ? { "x-dashboard-token": token } : {}),
...((options?.headers as Record<string, string>) || {}),
};
// A multipart upload: the browser writes Content-Type with its boundary.
if (typeof FormData !== "undefined" && options?.body instanceof FormData) {
delete headers["Content-Type"];
}
const res = await fetch(`${BASE}${path}`, { ...options, headers });
if (!res.ok) {
// Try to recover a structured error message from the JSON body; if the body
@@ -857,14 +875,35 @@ export const api = {
}),
/** POST /api/me/sign-out-others — end every login of this account but this one. */
signOutOthers: () => request<{ ended: number }>("/me/sign-out-others", { method: "POST" }),
/** URL of this account's avatar, versioned by `avatarAt` (null = none). */
avatarUrl: (avatarAt?: string | null) =>
avatarAt ? `${BASE}/me/avatar?v=${encodeURIComponent(avatarAt)}` : null,
/** PUT /api/me/avatar — one PNG, JPEG, GIF or WebP image, at most 1 MB. */
setAvatar: (file: Blob) =>
request<MyAccount>("/me/avatar", {
method: "PUT",
body: file,
headers: { "Content-Type": "application/octet-stream" },
}),
/** DELETE /api/me/avatar — back to the initials. */
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
/** URL of one account's avatar (admin/analyst), versioned by `avatarAt` (null = none). */
avatarUrl: (id: number, avatarAt?: string | null) =>
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () => request<{ users: ServerUserUsage[] }>("/users"),
/** GET /api/users/audit — staff reads of members' sessions and admins'
* account changes, newest first. */
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
/** GET /api/users/storage — each user's home size, measured in the background
* (`measuring` = more numbers are coming); `refresh` re-measures every home. */
storage: (refresh = false) =>
request<{ storage: Record<string, UserStorage>; measuring: boolean }>(
`/users/storage${refresh ? "?refresh=1" : ""}`
),
// Account changes (admin only). Each carries the admin's OWN password
// again (`currentPassword`): the server refuses without it (EREAUTH).
/** POST /api/users — a new account. */
@@ -2683,18 +2722,75 @@ export const api = {
`/artifacts/skills${laneId ? `?laneId=${laneId}` : ""}`
),
addPage: (body: { url: string; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/page", { method: "POST", body: JSON.stringify(body) }),
request<{ item: Artifact }>("/artifacts/page", {
method: "POST",
body: JSON.stringify(body),
}),
pinFile: (body: { laneId: number; path: string; title?: string; sessionId?: string }) =>
request<{ item: Artifact }>("/artifacts/file", { method: "POST", body: JSON.stringify(body) }),
request<{ item: Artifact }>("/artifacts/file", {
method: "POST",
body: JSON.stringify(body),
}),
addSkill: (body: { name: string; laneId?: number; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/skill", { method: "POST", body: JSON.stringify(body) }),
request<{ item: Artifact }>("/artifacts/skill", {
method: "POST",
body: JSON.stringify(body),
}),
addPlugin: (body: { key: string; market: string; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/plugin", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: number, body: { title?: string; description?: string; shared?: boolean }) =>
request<{ item: Artifact }>(`/artifacts/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
/** Read a marketplace (git link, owner/repo or marketplace.json link) and list its plugins. */
inspectMarket: (market: string) =>
request<MarketplaceInfo>("/artifacts/plugin/inspect", {
method: "POST",
body: JSON.stringify({ market }),
}),
/** A skill written in the dialog: SKILL.md gets `name`/`description` in its frontmatter. */
writeSkill: (body: { name: string; description: string; content: string; title?: string }) =>
request<{ item: Artifact; items: Artifact[] }>("/artifacts/skill/new", {
method: "POST",
body: JSON.stringify(body),
}),
/** Skills from files: one Markdown file, a folder, several files, a .zip/.skill, or
* several skills at once (one artifact each). `path` keeps a folder's layout. */
uploadSkills: (
files: { file: File; path: string }[],
fields: { name?: string; title?: string; description?: string } = {}
) => {
const form = new FormData();
for (const f of files) form.append("files", f.file, f.file.name);
form.append("paths", JSON.stringify(files.map((f) => f.path)));
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
return request<{ items: Artifact[] }>("/artifacts/skill/upload", {
method: "POST",
body: form,
});
},
/** One file from the user's computer, kept like a pinned one (≤ 25 MB). */
uploadFile: (file: File, fields: { title?: string; description?: string } = {}) => {
const form = new FormData();
form.append("files", file, file.name);
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
},
update: (
id: number,
body: {
title?: string;
description?: string;
shared?: boolean;
/** plugin only: `name@marketplace`. */
source?: string;
/** plugin only: the marketplace source. */
market?: string;
}
) =>
request<{ item: Artifact }>(`/artifacts/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
remove: (id: number) => request<{ ok: true }>(`/artifacts/${id}`, { method: "DELETE" }),
install: (id: number, replace = false) =>
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
@@ -3929,6 +4025,22 @@ export interface Artifact {
updatedAt: string;
}
/** POST /api/artifacts/plugin/inspect — a marketplace and the plugins it lists. */
export interface MarketplaceInfo {
/** The source as stored (owner/repo, a git URL or a marketplace.json link). */
market: string;
name: string;
description: string;
plugins: {
name: string;
/** `name@marketplace`, what `claude plugin install` takes. */
key: string;
description: string;
version: string;
category: string;
}[];
}
export interface ArtifactCandidate {
path: string;
sessionId: string;
+9
View File
@@ -251,6 +251,15 @@ export const SETTINGS_INDEX: {
descKey: "aiTools.description",
keywords: ["install", "update", "default"],
},
{
section: "agents",
titleKey: "artifacts:installsSwitch",
descKey: "artifacts:installsSwitchHint",
keywords: ["artifacts", "hub", "skill", "plugin", "cài đặt"],
to: "#artifacts-hub",
adminOnly: true,
serverOnly: true,
},
{ section: "agents/claude-code", titleKey: "hooks.title" },
{ section: "agents/claude-code", titleKey: "claudeHome.title" },
{ section: "agents/claude-code", titleKey: "ccConfig:tabs.skills", to: "?tab=skills" },
+2
View File
@@ -2673,6 +2673,8 @@ export interface Lane {
owner_name?: string | null;
/** The owner's display name (`users.display_name`); null when none is set. */
owner_display_name?: string | null;
/** When the owner's avatar was last set (`users.avatar_at`); null = none. */
owner_avatar_at?: string | null;
branch: string | null;
kind: "managed" | "adopted";
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
+58 -125
View File
@@ -2,11 +2,14 @@
* @file Artifacts.tsx
* @description `/artifacts` — the Artifacts hub (`/api/artifacts`,
* server/lib/artifacts.js): claude.ai pages sessions published, pinned
* workspace files, skills and plugins. **My library** lists the caller's
* workspace files (or one uploaded from the computer), skills (picked,
* uploaded as a file/folder/zip, or written — `ArtifactAddPanels.tsx`) and
* plugins (read from a marketplace link, or typed). **My library** lists the caller's
* own; in server mode the **Hub** tab lists what every user shared, and a
* shared skill or plugin is installed into the viewer's own account from
* there (after a confirmation that lists what will be written or run). An
* admin's switch turns Hub installs off for everyone. Previews of a file are
* admin's switch in Settings → AI tools (`ArtifactsHubSettings`) turns Hub
* installs off for everyone. Previews of a file are
* the server's sandboxed `raw` route in a sandboxed iframe, so nothing a
* shared HTML file carries runs on the dashboard's origin.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -40,7 +43,11 @@ import { eventBus } from "../lib/eventBus";
import { toast } from "../lib/toast";
import { confirmDialog } from "../lib/confirm";
import { useDialog } from "../hooks/useDialog";
import { Switch } from "../components/Switch";
import {
FileUploadPanel,
PluginAddPanel,
SkillAddPanel,
} from "../components/artifacts/ArtifactAddPanels";
const KIND_ICON = { page: Globe, file: FileText, skill: Sparkles, plugin: Package } as const;
const KINDS: ArtifactKind[] = ["page", "file", "skill", "plugin"];
@@ -176,26 +183,6 @@ export function Artifacts() {
<p className="text-sm text-fg-muted">{t(server ? "subtitleServer" : "subtitle")}</p>
</div>
<div className="flex items-center gap-3">
{server && settings?.canManage && (
<label
className="flex items-center gap-2 text-xs text-fg-secondary"
title={t("installsSwitchHint")}
>
<Switch
checked={settings.installsEnabled}
label={t("installsSwitch")}
onChange={async (v) => {
try {
const r = await api.artifacts.setInstallsEnabled(v);
setSettings({ ...settings, installsEnabled: r.installsEnabled });
} catch (err) {
toast.error(err);
}
}}
/>
{t("installsSwitch")}
</label>
)}
<div className="relative">
<button
type="button"
@@ -301,16 +288,7 @@ export function Artifacts() {
</ul>
)}
{adding && (
<AddDialog
kind={adding}
onClose={() => setAdding(null)}
onAdded={() => {
toast.success(t("added"));
load();
}}
/>
)}
{adding && <AddDialog kind={adding} onClose={() => setAdding(null)} onAdded={load} />}
{preview && <PreviewDialog {...preview} onClose={() => setPreview(null)} />}
{renaming && (
<RenameDialog
@@ -372,9 +350,7 @@ function ArtifactCard({
</span>
)}
</div>
{a.description && (
<p className="line-clamp-2 text-xs text-fg-secondary">{a.description}</p>
)}
{a.description && <p className="line-clamp-2 text-xs text-fg-secondary">{a.description}</p>}
<p className="text-[11px] text-fg-muted">
{server && a.owner && !a.mine ? `${t("by", { name: a.owner })} · ` : ""}
{new Date(a.createdAt).toLocaleString()}
@@ -495,6 +471,9 @@ function RenameDialog({
const { t } = useTranslation("artifacts");
const [title, setTitle] = useState(item.title);
const [description, setDescription] = useState(item.description);
const [source, setSource] = useState(item.source ?? "");
const [market, setMarket] = useState(item.market ?? "");
const plugin = item.kind === "plugin";
const field =
"w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
return (
@@ -504,7 +483,12 @@ function RenameDialog({
onSubmit={async (e) => {
e.preventDefault();
try {
await api.artifacts.update(item.id, { title, description });
await api.artifacts.update(item.id, {
title,
description,
...(plugin && source.trim() !== item.source ? { source: source.trim() } : {}),
...(plugin && market.trim() !== item.market ? { market: market.trim() } : {}),
});
onSaved();
} catch (err) {
toast.error(err);
@@ -525,8 +509,30 @@ function RenameDialog({
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
{plugin && (
<>
<input
className={field}
aria-label={t("form.pluginKey")}
placeholder="my-plugin@my-marketplace"
value={source}
onChange={(e) => setSource(e.target.value)}
/>
<input
className={field}
aria-label={t("form.market")}
placeholder="owner/repo · https://…"
value={market}
onChange={(e) => setMarket(e.target.value)}
/>
</>
)}
<div className="flex justify-end">
<button type="submit" className="btn-primary text-sm" disabled={!title.trim()}>
<button
type="submit"
className="btn-primary text-sm"
disabled={!title.trim() || (plugin && (!source.trim() || !market.trim()))}
>
{t("form.saveEdit")}
</button>
</div>
@@ -602,14 +608,9 @@ function AddDialog({
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [url, setUrl] = useState("");
const [key, setKey] = useState("");
const [market, setMarket] = useState("");
const [lanes, setLanes] = useState<Lane[]>([]);
const [laneId, setLaneId] = useState<number | null>(null);
const [candidates, setCandidates] = useState<ArtifactCandidate[] | null>(null);
const [skills, setSkills] = useState<{ name: string; from: "user" | "workspace" }[] | null>(
null
);
useEffect(() => {
if (kind !== "file" && kind !== "skill") return;
@@ -631,18 +632,13 @@ function AddDialog({
toast.error(err);
});
}
if (kind === "skill") {
api.artifacts
.skills(laneId ?? undefined)
.then((r) => setSkills(r.items))
.catch(() => setSkills([]));
}
}, [kind, laneId]);
const run = async (fn: () => Promise<unknown>, close = true) => {
setBusy(true);
try {
await fn();
toast.success(t("added"));
onAdded();
if (close) onClose();
} catch (err) {
@@ -719,6 +715,10 @@ function AddDialog({
{kind === "file" && (
<>
<FileUploadPanel onAdded={onAdded} />
<p className="border-t border-border-light pt-2 text-[11px] font-medium text-fg-secondary">
{t("file.fromWorkspace")}
</p>
{workspacePicker}
{laneId && candidates === null && (
<p className="text-xs text-fg-muted">{t("loading")}</p>
@@ -730,7 +730,10 @@ function AddDialog({
<ul className="max-h-80 space-y-0.5 overflow-auto">
{candidates.map((c) => (
<li key={c.path} className="flex items-center gap-2 text-xs">
<span className="min-w-0 flex-1 truncate font-mono text-fg-secondary" title={c.path}>
<span
className="min-w-0 flex-1 truncate font-mono text-fg-secondary"
title={c.path}
>
{c.path}
</span>
{!c.exists ? (
@@ -750,7 +753,9 @@ function AddDialog({
sessionId: c.sessionId,
});
setCandidates((list) =>
(list ?? []).map((x) => (x.path === c.path ? { ...x, pinned: true } : x))
(list ?? []).map((x) =>
x.path === c.path ? { ...x, pinned: true } : x
)
);
}, false)
}
@@ -765,81 +770,9 @@ function AddDialog({
</>
)}
{kind === "skill" && (
<>
{workspacePicker}
{skills && skills.length === 0 && (
<p className="text-xs text-fg-muted">{t("form.noSkills")}</p>
)}
{skills && skills.length > 0 && (
<ul className="max-h-72 space-y-0.5 overflow-auto">
{skills.map((s) => (
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
<span className="text-fg-muted">
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
</span>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy}
onClick={() =>
void run(() =>
api.artifacts.addSkill({
name: s.name,
...(s.from === "workspace" && laneId ? { laneId } : {}),
title: title || undefined,
description: description || undefined,
})
)
}
>
{t("form.save")}
</button>
</li>
))}
</ul>
)}
{meta}
</>
)}
{kind === "skill" && <SkillAddPanel lanes={lanes} onAdded={onAdded} onClose={onClose} />}
{kind === "plugin" && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(() => api.artifacts.addPlugin({ key, market, title, description }));
}}
>
<input
className={field}
placeholder="my-plugin@my-marketplace"
aria-label={t("form.pluginKey")}
value={key}
autoFocus
onChange={(e) => setKey(e.target.value)}
/>
<input
className={field}
placeholder="owner/repo"
aria-label={t("form.market")}
value={market}
onChange={(e) => setMarket(e.target.value)}
/>
{meta}
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={busy || !key.trim() || !market.trim()}
>
{t("form.save")}
</button>
</div>
</form>
)}
{kind === "plugin" && <PluginAddPanel onAdded={onAdded} onClose={onClose} />}
</div>
</Modal>
);
+3
View File
@@ -67,6 +67,7 @@ import { SnapshotSettings } from "../components/settings/SnapshotSettings";
import { ReportSettings } from "../components/settings/ReportSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { useAiTools } from "../hooks/useAiTools";
@@ -292,6 +293,8 @@ export function SettingsHub({ section }: { section?: string } = {}) {
<div className="space-y-6">
<AgentInstructionsSettings />
<AiToolsSettings settingsHref={toolHref} />
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
<ArtifactsHubSettings />
</div>
),
},
+81 -10
View File
@@ -2,8 +2,10 @@
* @file Users.tsx
* @description The Users page (`/users`, a sidebar item), server mode only and
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
* role, state, workspaces, sessions and the tokens/$ it used in the last 7 and
* 30 days (`GET /api/users`), the audit log (`GET /api/users/audit`), and — for
* role, state, workspaces, sessions, the tokens/$ it used in the last 7 and
* 30 days (`GET /api/users`) and how much disk its home folder takes
* (`GET /api/users/storage`, measured in the background — polled while the
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
* as one change), and the Claude/Codex account pool every user's sessions run
@@ -30,8 +32,14 @@ import {
UserPlus,
Users as UsersIcon,
} from "lucide-react";
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
import { fmt, fmtCost, formatDateTime } from "../lib/format";
import {
api,
type AuditEntry,
type AuthUser,
type ServerUserUsage,
type UserStorage,
} from "../lib/api";
import { fmt, fmtCost, formatBytes, formatDateTime } from "../lib/format";
import { toast } from "../lib/toast";
import { isSubmitEnter } from "../lib/keys";
import { useSpin } from "../hooks/useSpin";
@@ -56,10 +64,25 @@ export function Users() {
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [editing, setEditing] = useState<ServerUserUsage | null>(null);
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
const [measuring, setMeasuring] = useState(false);
const loadStorage = useCallback(
(remeasure = false) =>
api.users
.storage(remeasure)
.then((r) => {
setStorage(r.storage);
setMeasuring(r.measuring);
})
.catch(() => setMeasuring(false)),
[]
);
const load = useCallback(
() =>
(remeasure = false) =>
Promise.all([
loadStorage(remeasure),
api.users
.list()
.then((r) => {
@@ -72,14 +95,23 @@ export function Users() {
.then((r) => setAudit(r.entries))
.catch(() => setAudit([])),
]),
[]
[loadStorage]
);
const [spinning, refresh] = useSpin(load);
// The Refresh button also re-measures every home; a plain load reuses the
// server's cached numbers.
const [spinning, refresh] = useSpin(useCallback(() => load(true), [load]));
useEffect(() => {
if (me && me.role !== "developer") void load();
}, [me, load]);
// Homes are measured in the background: ask again until every number is in.
useEffect(() => {
if (!measuring) return;
const id = setTimeout(() => void loadStorage(), 4000);
return () => clearTimeout(id);
}, [measuring, storage, loadStorage]);
// Local mode, or a developer: there is no Users page for them.
if (!me || me.role === "developer") return <Navigate to="/" replace />;
@@ -168,7 +200,7 @@ export function Users() {
)}
<div className="card overflow-x-auto">
<table className="w-full min-w-[760px]" data-testid="users-table">
<table className="w-full min-w-[860px]" data-testid="users-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("name")}</th>
@@ -177,6 +209,7 @@ export function Users() {
<th className={`${th} text-right`}>{t("sessions")}</th>
<th className={`${th} text-right`}>{t("last7d")}</th>
<th className={`${th} text-right`}>{t("last30d")}</th>
<th className={`${th} text-right`}>{t("storage")}</th>
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
</tr>
</thead>
@@ -186,8 +219,8 @@ export function Users() {
Array.from({ length: 4 }).map((_, i) => (
<TableRowSkeleton
key={i}
columns={isAdmin ? 7 : 6}
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-28"]}
columns={isAdmin ? 8 : 7}
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-14", "w-28"]}
/>
))}
{users?.map((u) => {
@@ -244,6 +277,10 @@ export function Users() {
</td>
<UsageCell usage={u.last7d} />
<UsageCell usage={u.last30d} />
<StorageCell
storage={storage ? (storage[u.id] ?? null) : undefined}
measuring={measuring}
/>
{isAdmin && (
<td className="px-5 py-2 text-right whitespace-nowrap">
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
@@ -341,6 +378,40 @@ function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
);
}
/**
* A user's home folder size (`undefined` = not loaded yet). `null` while the
* server is still measuring reads "Measuring…", otherwise "—" (no home of
* their own).
*/
function StorageCell({ storage, measuring }: { storage?: UserStorage; measuring: boolean }) {
const { t } = useTranslation("users");
if (!storage) {
const pending = storage === undefined || measuring;
return (
<td
className="px-5 py-3 text-right text-sm tabular-nums text-fg-muted"
title={pending ? undefined : t("storageNone")}
>
{pending ? t("storageMeasuring") : "—"}
</td>
);
}
return (
<td
className="px-5 py-3 text-right tabular-nums"
title={storage.partial ? t("storagePartial") : undefined}
>
<div className="text-sm text-fg-primary">
{storage.partial ? "≥ " : ""}
{formatBytes(storage.bytes)}
</div>
<div className="text-[11px] text-fg-muted">
{t("storageHint", { files: fmt(storage.files), at: formatDateTime(storage.measuredAt) })}
</div>
</td>
);
}
function RowAction({
label,
disabled,
+106 -2
View File
@@ -2,11 +2,14 @@
* @file Tests for the `/artifacts` page: the library lists what the server
* answers, the Hub tab and Share appear only in server mode, a skill is
* installed only after a confirmation (and a taken name asks to replace),
* and a file preview is a sandboxed iframe with no `allow-*` token.
* and a file preview is a sandboxed iframe with no `allow-*` token. Adding:
* a plugin is picked from the list its marketplace link answers (a link the
* server cannot read opens the by-hand form), a skill is uploaded from one
* Markdown file under the name the user types, or written in the dialog.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Artifacts } from "../Artifacts";
@@ -24,6 +27,14 @@ vi.mock("../../lib/api", () => ({
remove: vi.fn(),
install: vi.fn(),
setInstallsEnabled: vi.fn(),
skills: vi.fn(),
addSkill: vi.fn(),
addPlugin: vi.fn(),
inspectMarket: vi.fn(),
uploadSkills: vi.fn(),
writeSkill: vi.fn(),
uploadFile: vi.fn(),
candidates: vi.fn(),
rawUrl: (id: number, path?: string) => `/api/artifacts/${id}/raw?path=${path ?? ""}`,
},
},
@@ -75,6 +86,99 @@ beforeEach(() => {
});
vi.mocked(confirmDialog).mockReset();
vi.mocked(api.artifacts.install).mockReset();
vi.mocked(api.lanes.list).mockResolvedValue({ lanes: [], counts: {} } as never);
vi.mocked(api.artifacts.skills).mockResolvedValue({ items: [] });
});
const addMenu = async (user: ReturnType<typeof userEvent.setup>, label: string) => {
open();
await screen.findByText("deploy");
await user.click(screen.getByTestId("artifacts-add"));
await user.click(screen.getByRole("button", { name: label }));
};
describe("Adding", () => {
it("adds the plugins picked from a marketplace link", async () => {
const user = userEvent.setup();
vi.mocked(api.artifacts.inspectMarket).mockResolvedValue({
market: "https://git.x.vn/o/skills.git",
name: "sg",
description: "",
plugins: [
{ name: "sg-dev", key: "sg-dev@sg", description: "Dev", version: "", category: "" },
{ name: "sg-common", key: "sg-common@sg", description: "", version: "", category: "" },
],
});
vi.mocked(api.artifacts.addPlugin).mockResolvedValue({ item: item({ kind: "plugin" }) });
await addMenu(user, "Plugin");
await user.type(screen.getByLabelText("Marketplace link"), "https://git.x.vn/o/skills.git");
await user.click(screen.getByRole("button", { name: "Read plugins" }));
await screen.findByTestId("market-plugins");
await user.click(screen.getByLabelText("Select all"));
await user.click(screen.getByRole("button", { name: "Add 2 plugin(s)" }));
await waitFor(() => expect(api.artifacts.addPlugin).toHaveBeenCalledTimes(2));
expect(api.artifacts.addPlugin).toHaveBeenCalledWith({
key: "sg-dev@sg",
market: "https://git.x.vn/o/skills.git",
title: "sg-dev",
description: "Dev",
});
});
it("opens the by-hand form when the link cannot be read", async () => {
const user = userEvent.setup();
vi.mocked(api.artifacts.inspectMarket).mockRejectedValue(
Object.assign(new Error("needs a login"), { code: "ENEEDLOGIN" })
);
await addMenu(user, "Plugin");
await user.type(screen.getByLabelText("Marketplace link"), "https://git.x.vn/o/private.git");
await user.click(screen.getByRole("button", { name: "Read plugins" }));
expect(await screen.findByRole("alert")).toHaveTextContent("needs a login");
expect(screen.getByLabelText("Marketplace (owner/repo or git URL)")).toHaveValue(
"https://git.x.vn/o/private.git"
);
});
it("uploads one Markdown file as a skill under the typed name", async () => {
const user = userEvent.setup();
vi.mocked(api.artifacts.uploadSkills).mockResolvedValue({ items: [item({ kind: "skill" })] });
await addMenu(user, "Skill");
const md = new File(["# Steps"], "notes.md", { type: "text/markdown" });
await user.upload(screen.getByTestId("skill-files"), md);
expect(await screen.findByTestId("skill-shape")).toHaveTextContent("becomes the skill's SKILL.md");
const name = screen.getByLabelText(/Skill name/);
await waitFor(() => expect(name).toHaveValue("notes"));
await user.clear(name);
await user.type(name, "Viết báo cáo");
expect(screen.getByTestId("skill-slug")).toHaveTextContent("/Viet-bao-cao");
await user.type(screen.getByLabelText(/Description/), "Use for reports");
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Add" }));
await waitFor(() => expect(api.artifacts.uploadSkills).toHaveBeenCalled());
const [files, fields] = vi.mocked(api.artifacts.uploadSkills).mock.calls[0]!;
expect(files.map((f) => f.path)).toEqual(["notes.md"]);
expect(fields).toMatchObject({ name: "Viết báo cáo", description: "Use for reports" });
});
it("writes a new skill", async () => {
const user = userEvent.setup();
vi.mocked(api.artifacts.writeSkill).mockResolvedValue({ item: item({}), items: [] });
await addMenu(user, "Skill");
await user.click(screen.getByRole("tab", { name: "Write new" }));
const add = within(screen.getByRole("dialog")).getByRole("button", { name: "Add" });
await user.type(screen.getByLabelText(/Skill name/), "standup");
await user.type(screen.getByLabelText(/Instructions/), "Summarise.");
expect(add).toBeDisabled(); // no description yet
await user.type(screen.getByLabelText(/Description/), "Daily standup");
await user.click(add);
await waitFor(() =>
expect(api.artifacts.writeSkill).toHaveBeenCalledWith({
name: "standup",
description: "Daily standup",
content: "Summarise.",
title: undefined,
})
);
});
});
describe("Artifacts", () => {
@@ -61,6 +61,9 @@ vi.mock("../../components/settings/AiToolsSettings", () => ({
vi.mock("../../components/settings/AgentInstructionsSettings", () => ({
AgentInstructionsSettings: () => <div data-testid="agent-instructions" />,
}));
vi.mock("../../components/settings/ArtifactsHubSettings", () => ({
ArtifactsHubSettings: () => null,
}));
vi.mock("../../components/settings/PricingSettings", () => ({
PricingSettings: ({ vendor }: { vendor: string }) => <div data-testid={`pricing-${vendor}`} />,
}));
+25 -1
View File
@@ -10,7 +10,7 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { AuthState, ServerUserUsage } from "../../lib/api";
@@ -34,6 +34,7 @@ const row = (id: number, username: string, role: ServerUserUsage["role"]): Serve
const users = vi.hoisted(() => ({
list: vi.fn(),
audit: vi.fn(),
storage: vi.fn(),
create: vi.fn(),
update: vi.fn(),
setPassword: vi.fn(),
@@ -74,6 +75,7 @@ beforeEach(() => {
users: [row(1, "boss", "admin"), row(2, "mia", "developer")],
});
users.audit.mockResolvedValue({ entries: [] });
users.storage.mockResolvedValue({ storage: {}, measuring: false });
users.get.mockImplementation(async (id: number) => ({
user: { id, username: "x", role: "developer", disabled: false },
workspaces: [],
@@ -91,6 +93,28 @@ describe("Users page", () => {
expect(users.list).not.toHaveBeenCalled();
});
it("shows each user's home size, and re-measures on Refresh", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
users.storage.mockResolvedValue({
storage: {
1: null,
2: {
bytes: 3 * 1024 ** 3,
files: 1200,
partial: false,
measuredAt: "2026-10-14T09:00:00Z",
},
},
measuring: false,
});
renderPage();
expect(await screen.findByText("3.0 GB")).toBeTruthy();
expect(screen.getByText("Storage")).toBeTruthy();
expect(users.storage).toHaveBeenLastCalledWith(false);
fireEvent.click(screen.getByRole("button", { name: /Refresh/ }));
await waitFor(() => expect(users.storage).toHaveBeenLastCalledWith(true));
});
it("shows an analyst the accounts with no controls", async () => {
authState = { mode: "server", user: { id: 3, username: "ana", role: "analyst" } };
renderPage();
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.7.9 (rebuild client to match)
UI build v2.8.0 (rebuild client to match)
</span>
</p>
</div>