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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}`} />,
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user