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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 17:21:33 +07:00
parent 8ed5018a2f
commit 7a88b74016
63 changed files with 3833 additions and 354 deletions
+55 -17
View File
@@ -1,6 +1,6 @@
/**
* @file Sidebar.tsx
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot — in server mode with the signed-in account above them (its avatar, name and role, linking to Settings → Account, and Log out). It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -79,6 +79,7 @@ import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
import { BRAND_LOGO, BRAND_MARK } from "../lib/brand";
import { useAuth, usePermissions } from "./AuthGate";
import { UserAvatar } from "./users/UserAvatar";
import { eventBus } from "../lib/eventBus";
import { WorkspaceTree } from "./WorkspaceTree";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
@@ -540,6 +541,59 @@ export function Sidebar({
)}
</div>
{/* Server mode: who is signed in — avatar, name and role, opening
Settings → Account — and Log out. On the rail they stack. */}
{auth.state.mode === "server" &&
auth.state.user &&
(() => {
const user = auth.state.user;
const name = user.displayName || user.username;
const role = t(`users:roles.${user.role}`);
const signedIn = t("common:auth.signedInAs", { name, role });
return (
<div
data-testid="sidebar-account"
className={`flex flex-shrink-0 gap-1 border-t border-border px-2 py-2 ${
collapsed ? "flex-col items-center" : "items-center"
}`}
>
<NavLink
to={settingsHref("account", location)}
title={signedIn}
aria-label={signedIn}
className={`flex min-w-0 items-center gap-2 rounded-md transition-colors hover:bg-surface-3 ${
collapsed ? "justify-center p-1" : "flex-1 px-1.5 py-1"
}`}
>
<UserAvatar
name={name}
avatarAt={user.avatarAt}
className={collapsed ? "h-7 w-7 text-[11px]" : "h-8 w-8 text-xs"}
/>
{!collapsed && (
<span className="min-w-0 leading-tight">
<span className="block truncate text-sm font-medium text-fg-primary">
{name}
</span>
<span className="block truncate text-[11px] text-fg-muted">{role}</span>
</span>
)}
</NavLink>
<button
type="button"
onClick={() => void auth.logout()}
title={`${signedIn} — ${t("common:auth.logout")}`}
aria-label={t("common:auth.logout")}
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "w-full py-2" : "h-8 w-8 flex-shrink-0"
}`}
>
<LogOut className="h-4 w-4" />
</button>
</div>
);
})()}
{/* Settings and the collapse toggle stick to the foot, out of the
scrolling nav. On the rail they stack. */}
<div
@@ -555,22 +609,6 @@ export function Sidebar({
<Settings className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{t("nav:settings")}</span>}
</NavLink>
{auth.state.mode === "server" && auth.state.user && (
<button
type="button"
onClick={() => void auth.logout()}
title={`${t("common:auth.signedInAs", {
name: auth.state.user.displayName || auth.state.user.username,
role: auth.state.user.role,
})} — ${t("common:auth.logout")}`}
aria-label={t("common:auth.logout")}
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
}`}
>
<LogOut className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={onToggle}
+11 -8
View File
@@ -10,8 +10,8 @@
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
* expanded lives in `projectTreePrefs.ts` (`expanded`). Server mode, admin or
* analyst: EVERY workspace, grouped under its owner (`owner_display_name`,
* else `owner_name`; yours
* analyst: EVERY workspace, grouped under its owner (their avatar —
* `owner_avatar_at`, `UserAvatar` — and `owner_display_name`, else `owner_name`; yours
* first, no owner last, groups foldable — `collapsedOwners`); one this user
* may not change (`canEditLane`) is read only — no menu, no new session, and a
* session opens its analysis page (`/sessions/<id>`), never a pane.
@@ -49,6 +49,7 @@ import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePr
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
import { UserAvatar } from "./users/UserAvatar";
// The sidebar is in the entry chunk; its two modals (and the run form the add
// modal pulls in) load when first opened.
@@ -366,12 +367,14 @@ export function WorkspaceTree() {
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)}
<span
aria-hidden
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-4 text-[9px] font-semibold uppercase text-fg-secondary"
>
{ownerShown(lane)[0] ?? "?"}
</span>
<UserAvatar
name={ownerShown(lane) || "?"}
userId={lane.owner_id ?? undefined}
avatarAt={lane.owner_avatar_at}
letters={1}
className="h-4 w-4 text-[9px]"
initialsClassName="bg-surface-4 text-fg-secondary"
/>
<span className="truncate">{ownerLabel}</span>
<span className="ml-auto shrink-0 tabular-nums">
{sorted.filter((l) => ownerKey(l) === owner).length}
@@ -0,0 +1,73 @@
/**
* @file Sidebar.account.test.tsx
* @description The sidebar's account row (server mode): the signed-in user's
* avatar — or initials without one — name and role, linking to Settings →
* Account, beside Sign out; local mode shows no row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
const auth = vi.hoisted(() => ({
state: { mode: "local" } as Record<string, unknown>,
logout: vi.fn(),
updateUser: vi.fn(),
}));
vi.mock("../AuthGate", () => ({
useAuth: () => auth,
usePermissions: () => ({
isAdmin: auth.state.mode !== "server",
local: auth.state.mode !== "server",
seesAll: true,
canEditLane: () => true,
}),
}));
import { Sidebar } from "../Sidebar";
const renderSidebar = (collapsed = false) =>
render(
<MemoryRouter>
<Sidebar
wsConnected
collapsed={collapsed}
onToggle={() => {}}
connectionOpen={false}
onConnectionOpenChange={() => {}}
/>
</MemoryRouter>
);
describe("Sidebar account row", () => {
it("shows the avatar, name and role, linking to Settings → Account", () => {
auth.state = {
mode: "server",
user: { id: 2, username: "mia", role: "developer", displayName: "Mia Tran", avatarAt: "t1" },
};
renderSidebar();
const row = screen.getByTestId("sidebar-account");
expect(row.textContent).toContain("Mia Tran");
expect(row.textContent).toContain("Developer");
expect(within(row).getByTestId("user-avatar-img").getAttribute("src")).toBe(
"/api/me/avatar?v=t1"
);
expect(within(row).getAllByRole("link")[0]?.getAttribute("href")).toBe("/settings/account");
expect(within(row).getByRole("button", { name: "Sign out" })).toBeTruthy();
});
it("falls back to initials, and keeps only the avatar on the rail", () => {
auth.state = { mode: "server", user: { id: 3, username: "leo", role: "admin" } };
renderSidebar(true);
const row = screen.getByTestId("sidebar-account");
expect(within(row).getByTestId("user-avatar-initials").textContent).toBe("le");
expect(row.textContent).not.toContain("Admin");
});
it("has no account row in local mode", () => {
auth.state = { mode: "local" };
renderSidebar();
expect(screen.queryByTestId("sidebar-account")).toBeNull();
});
});
@@ -1,7 +1,8 @@
/**
* @file WorkspaceTree.owners.test.tsx
* @description Server mode, the sidebar workspace list for an analyst: every
* user's workspaces, grouped under their owner (yours first, no owner last),
* user's workspaces, grouped under their owner (with their avatar, else an
* initial; yours first, no owner last),
* groups folding and remembered; another user's workspace is read only — no
* menu, no new session, and a session opens its analysis page, never a pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -30,7 +31,11 @@ const lane = (id: number, owner: number | null, name: string | null, title: stri
}) as unknown as Lane;
const lanes = [
{ ...lane(1, 3, "bob", "Bob app"), owner_display_name: "Bob Nguyen" } as Lane,
{
...lane(1, 3, "bob", "Bob app"),
owner_display_name: "Bob Nguyen",
owner_avatar_at: "t1",
} as Lane,
lane(2, null, null, "Old"),
lane(3, ME.id, "ana", "Mine"),
lane(4, 1, "admin", "Admin site"),
@@ -61,6 +66,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
})),
},
run: { list: vi.fn(async () => ({ items: [] })) },
users: {
avatarUrl: (id: number, at?: string | null) =>
at ? `/api/users/${id}/avatar?v=${at}` : null,
},
me: { avatarUrl: () => null },
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
},
};
@@ -118,6 +128,15 @@ describe("WorkspaceTree for an analyst", () => {
await screen.findByTestId("workspace-row-1");
expect(screen.getByTestId("workspace-owner-3")).toHaveTextContent("Bob Nguyen");
expect(screen.getByTestId("workspace-owner-3")).not.toHaveTextContent("bob");
// Bob set a picture: shown beside the name; Ana did not: her initial.
expect(
within(screen.getByTestId("workspace-owner-3"))
.getByTestId("user-avatar-img")
.getAttribute("src")
).toBe("/api/users/3/avatar?v=t1");
expect(
within(screen.getByTestId("workspace-owner-2")).getByTestId("user-avatar-initials")
).toHaveTextContent("a");
expect(screen.getByTestId("workspace-owner-1")).toHaveTextContent("admin");
});
@@ -0,0 +1,928 @@
/**
* @file ArtifactAddPanels.tsx
* @description The Artifacts page's Add dialogs for a skill, a plugin and an
* uploaded file (server/lib/artifacts.js):
* - `SkillAddPanel` — three ways to make a skill: **Upload** (one Markdown
* file that becomes SKILL.md, several files, a whole folder — picked or
* dropped — or a .zip/.skill; an upload holding several skills makes one
* artifact each), **Write** (name, description, instructions) and
* **Existing** (a folder in the user's ~/.claude/skills or a workspace's
* .claude/skills). The user names the skill; the name is previewed as the
* folder and `/command` it installs as (`skillSlug`, the server's rule).
* - `PluginAddPanel` — paste a marketplace link (a git https link, a forge
* web link, `owner/repo` or a marketplace.json link), read its plugin list
* and tick the ones to add; or type `name@marketplace` by hand, which is
* also where a link the server cannot read (ssh, a private repo) ends up.
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { FileText, FolderOpen, Package, Sparkles, Upload, X } from "lucide-react";
import { api, type MarketplaceInfo } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { toast } from "../../lib/toast";
const field = "w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
const label = "block text-[11px] font-medium text-fg-secondary";
const hint = "text-[11px] text-fg-muted";
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
export function skillSlug(text: string): string {
return text
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/đ/g, "d")
.replace(/Đ/g, "D")
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/^[-._]+|-+$/g, "")
.slice(0, 100);
}
/** `field:` of a Markdown file's frontmatter, or "". */
export function frontmatterField(text: string, name: string): string {
const fm = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text);
if (!fm) return "";
const m = new RegExp(`^${name}:\\s*(.*)$`, "m").exec(fm[1] ?? "");
return (m?.[1] ?? "").trim().replace(/^(["'])(.*)\1$/, "$2");
}
type Picked = { file: File; path: string };
/** A small file's text ("" on failure), only to prefill the name/description. */
function readText(file: File): Promise<string> {
if (file.size > 1024 * 1024) return Promise.resolve("");
return new Promise((ok) => {
const r = new FileReader();
r.onload = () => ok(typeof r.result === "string" ? r.result : "");
r.onerror = () => ok("");
r.readAsText(file);
});
}
const JUNK = /(^|\/)(__MACOSX|\.DS_Store|Thumbs\.db|desktop\.ini|\.git|node_modules)(\/|$)/;
const isSkillMd = (p: string) => /(^|\/)skill\.md$/i.test(p);
const isArchive = (p: string) => /\.(zip|skill)$/i.test(p);
const isText = (p: string) => /\.(md|markdown|txt)$/i.test(p);
/** Every file under what was dropped, folders walked (webkitGetAsEntry). */
async function droppedFiles(e: DragEvent): Promise<Picked[]> {
const items = Array.from(e.dataTransfer.items || []);
const entries = items
.map((i) => (i.webkitGetAsEntry ? i.webkitGetAsEntry() : null))
.filter((x): x is FileSystemEntry => !!x);
if (!entries.length) {
return Array.from(e.dataTransfer.files).map((file) => ({ file, path: file.name }));
}
const out: Picked[] = [];
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
if (entry.isFile) {
const file = await new Promise<File>((ok, fail) =>
(entry as FileSystemFileEntry).file(ok, fail)
);
out.push({ file, path: prefix + entry.name });
return;
}
const reader = (entry as FileSystemDirectoryEntry).createReader();
// readEntries answers in batches until an empty one.
for (;;) {
const batch = await new Promise<FileSystemEntry[]>((ok, fail) =>
reader.readEntries(ok, fail)
);
if (!batch.length) break;
for (const child of batch) await walk(child, `${prefix}${entry.name}/`);
}
};
for (const entry of entries) await walk(entry, "");
return out;
}
/** What an upload will make, guessed the way the server decides it. */
function describeUpload(files: Picked[]): {
kind: "none" | "single-md" | "archive" | "one" | "many" | "no-skill-md";
count: number;
folder: string;
} {
const real = files.filter((f) => !JUNK.test(f.path));
if (!real.length) return { kind: "none", count: 0, folder: "" };
if (real.length === 1 && isArchive(real[0]!.path))
return { kind: "archive", count: 0, folder: "" };
const roots = [
...new Set(
real.filter((f) => isSkillMd(f.path)).map((f) => f.path.split("/").slice(0, -1).join("/"))
),
].sort((a, b) => a.length - b.length);
const under = (dir: string, p: string) => dir === "" || p === dir || p.startsWith(`${dir}/`);
const top = roots.filter((r, i) => !roots.slice(0, i).some((o) => under(o, r)));
if (!top.length) {
return real.length === 1 && isText(real[0]!.path)
? { kind: "single-md", count: 1, folder: "" }
: { kind: "no-skill-md", count: 0, folder: "" };
}
if (top.length === 1) return { kind: "one", count: 1, folder: top[0]!.split("/").pop() ?? "" };
return { kind: "many", count: top.length, folder: "" };
}
function Tabs<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: { id: T; label: string }[];
onChange: (v: T) => void;
}) {
return (
<div role="tablist" className="flex rounded border border-border bg-surface-2 p-0.5">
{options.map((o) => (
<button
key={o.id}
type="button"
role="tab"
aria-selected={value === o.id}
onClick={() => onChange(o.id)}
className={`flex-1 rounded px-2 py-1 text-xs ${
value === o.id
? "bg-accent-solid text-white"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{o.label}
</button>
))}
</div>
);
}
/** The name field with the folder/command it becomes. */
function SkillNameField({
value,
onChange,
disabled,
required,
}: {
value: string;
onChange: (v: string) => void;
disabled?: boolean;
required?: boolean;
}) {
const { t } = useTranslation("artifacts");
const slug = skillSlug(value);
return (
<div className="space-y-0.5">
<label className={label}>
{t("skill.name")}
{required ? " *" : ""}
<input
className={`${field} mt-0.5`}
value={value}
disabled={disabled}
placeholder="release-notes"
onChange={(e) => onChange(e.target.value)}
/>
</label>
{value.trim() && (
<p className={hint} data-testid="skill-slug">
{slug ? t("skill.slugHint", { slug }) : t("skill.slugBad")}
</p>
)}
</div>
);
}
// ── Skill ─────────────────────────────────────────────────────────────────
export function SkillAddPanel({
lanes,
onAdded,
onClose,
}: {
lanes: Lane[];
onAdded: () => void;
onClose: () => void;
}) {
const { t } = useTranslation("artifacts");
const [mode, setMode] = useState<"upload" | "write" | "existing">("upload");
const [busy, setBusy] = useState(false);
const [name, setName] = useState("");
const [nameTouched, setNameTouched] = useState(false);
const [description, setDescription] = useState("");
const [descTouched, setDescTouched] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [files, setFiles] = useState<Picked[]>([]);
const [dragging, setDragging] = useState(false);
const [laneId, setLaneId] = useState<number | null>(null);
const [skills, setSkills] = useState<{ name: string; from: "user" | "workspace" }[] | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
// `webkitdirectory` is not in React's typings.
useEffect(() => {
folderInput.current?.setAttribute("webkitdirectory", "");
}, [mode]);
useEffect(() => {
if (mode !== "existing") return;
api.artifacts
.skills(laneId ?? undefined)
.then((r) => setSkills(r.items))
.catch(() => setSkills([]));
}, [mode, laneId]);
const shape = useMemo(() => describeUpload(files), [files]);
// Fill the name and description from what was picked, unless typed.
useEffect(() => {
if (!files.length) return;
const md =
files.find((f) => isSkillMd(f.path) && !JUNK.test(f.path)) ??
(shape.kind === "single-md" ? files.find((f) => !JUNK.test(f.path)) : undefined);
const stem = (p: string) => (p.split("/").pop() ?? "").replace(/\.[^.]+$/, "");
let live = true;
void (async () => {
const text = md ? await readText(md.file) : "";
if (!live) return;
const fmName = frontmatterField(text, "name");
const fmDesc = frontmatterField(text, "description");
if (!nameTouched) {
const guess =
shape.kind === "single-md"
? fmName || stem(md?.path ?? "")
: shape.kind === "one"
? fmName || shape.folder
: shape.kind === "archive"
? stem(files[0]!.path)
: "";
setName(guess);
}
if (!descTouched && fmDesc) setDescription(fmDesc);
})();
return () => {
live = false;
};
// Refill only when the pick changes, never on a typed name.
}, [files, shape]);
const pick = (list: FileList | null) => {
if (!list?.length) return;
setFiles(
Array.from(list).map((file) => ({
file,
path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
}))
);
};
const run = async (fn: () => Promise<number>) => {
setBusy(true);
try {
const n = await fn();
toast.success(n > 1 ? t("skill.addedMany", { count: n }) : t("added"));
onAdded();
onClose();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
};
const many = shape.kind === "many";
const total = files.reduce((n, f) => n + f.file.size, 0);
const meta = (
<>
<label className={label}>
{t("skill.description")}
<textarea
className={`${field} mt-0.5`}
rows={2}
value={description}
disabled={many}
placeholder={t("skill.descriptionHint")}
onChange={(e) => {
setDescTouched(true);
setDescription(e.target.value);
}}
/>
</label>
<label className={label}>
{t("form.title")}
<input
className={`${field} mt-0.5`}
value={title}
disabled={many}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
</>
);
return (
<div className="space-y-3">
<Tabs
value={mode}
onChange={setMode}
options={[
{ id: "upload", label: t("skill.tab.upload") },
{ id: "write", label: t("skill.tab.write") },
{ id: "existing", label: t("skill.tab.existing") },
]}
/>
{mode === "upload" && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
const r = await api.artifacts.uploadSkills(files, {
name: many ? undefined : name.trim(),
description: many ? undefined : description.trim(),
title: many ? undefined : title.trim(),
});
return r.items.length;
});
}}
>
<div
data-testid="skill-drop"
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
void droppedFiles(e).then((list) => list.length && setFiles(list));
}}
className={`rounded-lg border border-dashed p-4 text-center ${
dragging ? "border-accent bg-accent/10" : "border-border"
}`}
>
<Upload className="mx-auto mb-1 h-5 w-5 text-fg-muted" />
<p className="text-xs text-fg-secondary">{t("skill.dropHint")}</p>
<div className="mt-2 flex flex-wrap justify-center gap-2">
<button
type="button"
className="btn-ghost text-xs"
onClick={() => fileInput.current?.click()}
>
<FileText className="h-3.5 w-3.5" /> {t("skill.pickFiles")}
</button>
<button
type="button"
className="btn-ghost text-xs"
onClick={() => folderInput.current?.click()}
>
<FolderOpen className="h-3.5 w-3.5" /> {t("skill.pickFolder")}
</button>
</div>
<input
ref={fileInput}
type="file"
multiple
hidden
data-testid="skill-files"
onChange={(e) => {
pick(e.target.files);
e.target.value = "";
}}
/>
<input
ref={folderInput}
type="file"
multiple
hidden
data-testid="skill-folder"
onChange={(e) => {
pick(e.target.files);
e.target.value = "";
}}
/>
</div>
{files.length > 0 && (
<div className="rounded border border-border bg-surface-2 p-2">
<div className="mb-1 flex items-center gap-2">
<p className="flex-1 text-xs text-fg-primary" data-testid="skill-shape">
{t(`skill.shape.${shape.kind}`, { count: shape.count })}
</p>
<span className={hint}>
{t("files", { count: files.length })} · {Math.ceil(total / 1024)} KB
</span>
<button
type="button"
aria-label={t("skill.clear")}
className="text-fg-muted hover:text-fg-primary"
onClick={() => {
setFiles([]);
setNameTouched(false);
setDescTouched(false);
}}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<ul className="max-h-28 overflow-auto font-mono text-[11px] text-fg-secondary">
{files.slice(0, 200).map((f) => (
<li
key={f.path}
className={`truncate ${JUNK.test(f.path) ? "line-through opacity-50" : ""}`}
>
{f.path}
</li>
))}
</ul>
</div>
)}
{many ? (
<p className={hint}>{t("skill.manyHint")}</p>
) : (
<SkillNameField
value={name}
onChange={(v) => {
setNameTouched(true);
setName(v);
}}
required={shape.kind === "single-md"}
/>
)}
{meta}
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={
busy ||
!files.length ||
shape.kind === "no-skill-md" ||
(shape.kind === "single-md" && !skillSlug(name))
}
>
{busy ? t("skill.uploading") : t("form.save")}
</button>
</div>
</form>
)}
{mode === "write" && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
await api.artifacts.writeSkill({
name: name.trim(),
description: description.trim(),
content,
title: title.trim() || undefined,
});
return 1;
});
}}
>
<SkillNameField value={name} onChange={setName} required />
<label className={label}>
{t("skill.description")} *
<textarea
className={`${field} mt-0.5`}
rows={2}
value={description}
placeholder={t("skill.descriptionHint")}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
<label className={label}>
{t("skill.content")} *
<textarea
className={`${field} mt-0.5 font-mono text-xs`}
rows={10}
value={content}
placeholder={t("skill.contentHint")}
onChange={(e) => setContent(e.target.value)}
/>
</label>
<label className={label}>
{t("form.title")}
<input
className={`${field} mt-0.5`}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={
busy ||
!skillSlug(name) ||
!content.trim() ||
(!description.trim() && !frontmatterField(content, "description"))
}
>
{t("form.save")}
</button>
</div>
</form>
)}
{mode === "existing" && (
<div className="space-y-2">
<select
className={field}
aria-label={t("form.workspace")}
value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">{t("form.pickWorkspace")}</option>
{lanes.map((l) => (
<option key={l.id} value={l.id}>
{l.title || l.cwd}
</option>
))}
</select>
{skills && skills.length === 0 && <p className={hint}>{t("form.noSkills")}</p>}
{skills && skills.length > 0 && (
<ul className="max-h-72 space-y-0.5 overflow-auto">
{skills.map((s) => (
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
<span className="text-fg-muted">
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
</span>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy}
onClick={() =>
void run(async () => {
await api.artifacts.addSkill({
name: s.name,
...(s.from === "workspace" && laneId ? { laneId } : {}),
title: title || undefined,
description: description || undefined,
});
return 1;
})
}
>
{t("form.save")}
</button>
</li>
))}
</ul>
)}
<input
className={field}
placeholder={t("form.title")}
aria-label={t("form.title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className={field}
rows={2}
placeholder={t("form.description")}
aria-label={t("form.description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
)}
</div>
);
}
// ── Plugin ────────────────────────────────────────────────────────────────
const MANUAL_CODES = new Set(["ENEEDLOGIN", "ESSHMARKET", "ENOTMARKET", "EMARKETFETCH"]);
export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onClose: () => void }) {
const { t } = useTranslation("artifacts");
const [link, setLink] = useState("");
const [busy, setBusy] = useState(false);
const [info, setInfo] = useState<MarketplaceInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [picked, setPicked] = useState<Set<string>>(new Set());
const [have, setHave] = useState<Set<string>>(new Set());
const [manual, setManual] = useState(false);
const [key, setKey] = useState("");
const [market, setMarket] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
useEffect(() => {
api.artifacts
.list({ scope: "mine", kind: "plugin" })
.then((r) => setHave(new Set(r.items.map((i) => i.source ?? ""))))
.catch(() => setHave(new Set()));
}, []);
const read = async () => {
setBusy(true);
setError(null);
setInfo(null);
try {
const r = await api.artifacts.inspectMarket(link.trim());
setInfo(r);
setPicked(
new Set(
r.plugins
.filter((p) => !have.has(p.key))
.slice(0, 1)
.map((p) => p.key)
)
);
setMarket(r.market);
} catch (err) {
const e = err as Error & { code?: string };
setError(e.message);
if (MANUAL_CODES.has(e.code ?? "")) {
setManual(true);
setMarket(link.trim());
}
} finally {
setBusy(false);
}
};
const addPicked = async () => {
if (!info) return;
setBusy(true);
let n = 0;
try {
for (const p of info.plugins.filter((x) => picked.has(x.key))) {
await api.artifacts.addPlugin({
key: p.key,
market: info.market,
title: p.name,
description: p.description,
});
n++;
}
toast.success(t("plugin.addedMany", { count: n }));
onAdded();
onClose();
} catch (err) {
toast.error(err);
if (n) onAdded();
} finally {
setBusy(false);
}
};
const toggle = (k: string) =>
setPicked((s) => {
const next = new Set(s);
if (next.has(k)) next.delete(k);
else next.add(k);
return next;
});
return (
<div className="space-y-3">
<form
className="space-y-1"
onSubmit={(e) => {
e.preventDefault();
if (link.trim()) void read();
}}
>
<label className={label}>
{t("plugin.link")}
<div className="mt-0.5 flex gap-2">
<input
className={field}
value={link}
autoFocus
placeholder="https://git.example.com/org/claude-skills.git"
onChange={(e) => setLink(e.target.value)}
/>
<button
type="submit"
className="btn-primary shrink-0 text-sm"
disabled={busy || !link.trim()}
>
{busy && !info ? t("plugin.reading") : t("plugin.read")}
</button>
</div>
</label>
<p className={hint}>{t("plugin.linkHint")}</p>
</form>
{error && (
<p
role="alert"
className="rounded border border-status-danger/40 px-2 py-1 text-xs text-status-danger"
>
{error}
</p>
)}
{info && (
<div className="space-y-2" data-testid="market-plugins">
<div>
<p className="text-sm font-medium text-fg-primary">
<Package className="mr-1 inline h-4 w-4 text-fg-muted" />
{info.name}
</p>
{info.description && <p className={hint}>{info.description}</p>}
</div>
{info.plugins.length === 0 ? (
<p className={hint}>{t("plugin.none")}</p>
) : (
<>
<label className="flex items-center gap-2 text-xs text-fg-secondary">
<input
type="checkbox"
checked={picked.size === info.plugins.length}
onChange={(e) =>
setPicked(new Set(e.target.checked ? info.plugins.map((p) => p.key) : []))
}
/>
{t("plugin.all")}
</label>
<ul className="max-h-64 space-y-1 overflow-auto">
{info.plugins.map((p) => (
<li key={p.key}>
<label className="flex cursor-pointer items-start gap-2 rounded px-1 py-1 hover:bg-surface-2">
<input
type="checkbox"
className="mt-0.5"
checked={picked.has(p.key)}
onChange={() => toggle(p.key)}
/>
<span className="min-w-0 flex-1">
<span className="text-xs font-medium text-fg-primary">{p.name}</span>
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">{p.key}</span>
{p.version && (
<span className="ml-1.5 text-[10px] text-fg-muted">v{p.version}</span>
)}
{have.has(p.key) && (
<span className="ml-1.5 rounded bg-surface-3 px-1 text-[10px] text-fg-secondary">
{t("plugin.have")}
</span>
)}
{p.description && (
<span className="block text-[11px] text-fg-muted">{p.description}</span>
)}
</span>
</label>
</li>
))}
</ul>
<div className="flex justify-end">
<button
type="button"
className="btn-primary text-sm"
disabled={busy || picked.size === 0}
onClick={() => void addPicked()}
>
{t("plugin.addPicked", { count: picked.size })}
</button>
</div>
</>
)}
</div>
)}
<div className="border-t border-border-light pt-2">
<button
type="button"
className="text-xs text-accent hover:underline"
aria-expanded={manual}
onClick={() => setManual((v) => !v)}
>
{t("plugin.manual")}
</button>
{manual && (
<form
className="mt-2 space-y-2"
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
try {
await api.artifacts.addPlugin({
key: key.trim(),
market: market.trim(),
title,
description,
});
toast.success(t("added"));
onAdded();
onClose();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
}}
>
<input
className={field}
placeholder="my-plugin@my-marketplace"
aria-label={t("form.pluginKey")}
value={key}
onChange={(e) => setKey(e.target.value)}
/>
<input
className={field}
placeholder="owner/repo · https://… · git@host:org/repo.git"
aria-label={t("form.market")}
value={market}
onChange={(e) => setMarket(e.target.value)}
/>
<input
className={field}
placeholder={t("form.title")}
aria-label={t("form.title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className={field}
rows={2}
placeholder={t("form.description")}
aria-label={t("form.description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={busy || !key.trim() || !market.trim()}
>
{t("form.save")}
</button>
</div>
</form>
)}
</div>
</div>
);
}
// ── File upload ───────────────────────────────────────────────────────────
export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
const { t } = useTranslation("artifacts");
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [busy, setBusy] = useState(false);
const tooBig = !!file && file.size > 25 * 1024 * 1024;
return (
<form
className="space-y-2"
onSubmit={async (e) => {
e.preventDefault();
if (!file) return;
setBusy(true);
try {
await api.artifacts.uploadFile(file, { title: title.trim() || undefined });
toast.success(t("added"));
setFile(null);
setTitle("");
onAdded();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
}}
>
<label className={label}>
{t("file.fromComputer")}
<input
type="file"
data-testid="file-upload"
className="mt-0.5 block w-full text-xs text-fg-secondary"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
{tooBig && <p className="text-xs text-status-danger">{t("file.tooBig")}</p>}
<div className="flex gap-2">
<input
className={field}
placeholder={t("form.title")}
aria-label={t("form.title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<button
type="submit"
className="btn-primary shrink-0 text-sm"
disabled={busy || !file || tooBig}
>
<Upload className="h-3.5 w-3.5" /> {t("file.upload")}
</button>
</div>
</form>
);
}
+73 -3
View File
@@ -25,7 +25,10 @@
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
* of another tab, a tab (or a pane header) joins that tab instead
* (`joinGroup`: side by side, then under the right pane, then under the left
* one, up to four panes). A right-click on a tab
* one, up to four panes). A pane drag held over another tab for `SPRING_MS` shows that tab
* (spring-loaded, as a file manager's folder), so the pane can be dropped on
* any of its panes' edges; near either end of an overflowing bar a drag
* scrolls it. A right-click on a tab
* opens the same actions as a menu (`ContextMenu`). With tabs from every
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
* and `workspaceOf` names each tab's workspace before its own name. Settings
@@ -57,7 +60,14 @@ import { isSubmitEnter } from "../../lib/keys";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
import {
PANE_DRAG_TYPE,
RunIcon,
paneDragSlot,
setDragChip,
startPaneDrag,
type GroupsUpdate,
} from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import {
type PaneGroups,
@@ -155,6 +165,11 @@ interface Tab {
const URGENCY: RunState[] = ["awaiting", "error", "running"];
/** How long a pane drag rests on a tab before that tab is shown. */
export const SPRING_MS = 600;
/** Within this many px of an overflowing bar's end, a drag scrolls it. */
const EDGE_SCROLL_PX = 40;
/** The Settings tab's key among the tabs' (a group's key is its slots). */
const SETTINGS_KEY = "settings";
@@ -256,8 +271,59 @@ export function ConsoleTabs({
if (!tab || dragged.current.some((s) => tab.slots.includes(s))) return null;
return tab.slots.length + Math.max(dragged.current.length, 1) <= MAX_PANES ? i : null;
};
// A pane drag resting on a tab (its drawn index) shows it after SPRING_MS.
const spring = useRef<{ at: number; timer: ReturnType<typeof setTimeout> } | null>(null);
const stopSpring = () => {
if (spring.current) clearTimeout(spring.current.timer);
spring.current = null;
};
useEffect(() => stopSpring, []);
const hoveredIndexAt = (x: number) => {
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
return els.findIndex((el) => {
const b = el.getBoundingClientRect();
return x >= b.left && x <= b.right;
});
};
const springAt = (e: DragEvent) => {
const slot = paneDragSlot();
const i = hoveredIndexAt(e.clientX);
const tab = tabs[i];
// Only a pane drag, onto a tab not on screen that does not hold it.
const target =
slot !== null &&
tab &&
!settings?.active &&
tab.index !== groups.active &&
!tab.slots.includes(slot)
? i
: null;
if ((spring.current?.at ?? null) === target) return;
stopSpring();
if (target === null || !tab) return;
const index = tab.index;
spring.current = {
at: target,
timer: setTimeout(() => {
spring.current = null;
if (paneDragSlot() === null) return;
onGroupsChange((g) =>
g.active === index || !g.groups[index] ? g : { ...g, active: index }
);
}, SPRING_MS),
};
};
// An overflowing bar has no scrollbar: near either end, a drag scrolls it.
const edgeScroll = (x: number) => {
const list = listRef.current;
if (!list || list.scrollWidth <= list.clientWidth) return;
const b = list.getBoundingClientRect();
if (x < b.left + EDGE_SCROLL_PX) list.scrollLeft -= 16;
else if (x > b.right - EDGE_SCROLL_PX) list.scrollLeft += 16;
};
const endDrag = () => {
dragged.current = [];
stopSpring();
setDropAt(null);
setJoinAt(null);
};
@@ -266,6 +332,8 @@ export function ConsoleTabs({
if (!isBarDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
edgeScroll(e.clientX);
springAt(e);
const join = joinIndexAt(e.clientX);
const at = join === null ? dropIndexAt(e.clientX) : null;
setJoinAt((d) => (d === join ? d : join));
@@ -273,6 +341,7 @@ export function ConsoleTabs({
},
onDragLeave: (e: DragEvent) => {
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
stopSpring();
setDropAt(null);
setJoinAt(null);
},
@@ -504,10 +573,11 @@ export function ConsoleTabs({
draggable
onDragStart={(e) => {
dragged.current = tab.slots;
if (single) startPaneDrag(e, single.slot ?? 0);
if (single) startPaneDrag(e, single.slot ?? 0, label(single));
else {
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
setDragChip(e, tabLabel(tab));
}
}}
onContextMenu={(e) => tabCtx.open(e, tab)}
+62 -9
View File
@@ -14,7 +14,13 @@
* one tab's panes; `onChange` applies through `updateActive`, so a session
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
* shows its state dot, as its tab does.
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). While a
* drag is on, the pane it carries is dimmed, the browser's ghost is a small
* chip with its name (`setDragChip`) instead of a whole header, and the drop
* preview names what the drop will do (`dropAction`). The dragged pane's
* header stays mounted even once the tab bar has switched tabs under the
* drag (`ConsoleTabs` spring-loads a hovered tab) — removing the drag's
* source node would lose its `dragend`. A
* right-click on a header offers the swap, a split with a new terminal and
* the close as one menu. Dividers resize by pointer or by arrow keys; a drag
* moves only the line and commits the ratio on release, so the layout is
@@ -69,11 +75,31 @@ export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
let dragSlot: number | null = null;
/** Starts dragging a pane or a tab (`slot`) onto the grid. */
export function startPaneDrag(e: React.DragEvent, slot: number): void {
/** The slot a pane drag carries, or null (no pane drag is on). */
export const paneDragSlot = (): number | null => dragSlot;
/** The drag's ghost: a small chip naming what moves, not a snapshot of a
* whole pane header or tab. Drawn off screen for the browser to copy. */
export function setDragChip(e: React.DragEvent, label: string): void {
const dt = e.dataTransfer;
if (!label || typeof dt?.setDragImage !== "function") return;
const chip = document.createElement("div");
chip.textContent = label;
chip.className =
"fixed -top-96 left-0 max-w-[16rem] truncate rounded-md border border-accent/60 bg-surface-2 px-2.5 py-1 text-xs font-medium text-fg-primary shadow-lg";
document.body.appendChild(chip);
dt.setDragImage(chip, 12, 12);
// The browser copies it during dragstart; it may go right after.
window.setTimeout(() => chip.remove(), 0);
}
/** Starts dragging a pane or a tab (`slot`) onto the grid; `label` names it
* on the drag's chip. */
export function startPaneDrag(e: React.DragEvent, slot: number, label?: string): void {
dragSlot = slot;
e.dataTransfer?.setData(PANE_DRAG_TYPE, String(slot));
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
if (label) setDragChip(e, label);
}
/** A run's icon: a shell's terminal glyph, else its AI tool's mark. */
@@ -140,6 +166,14 @@ export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone
return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null;
}
/** What a drop does, as an i18n key (`lanes` namespace): the center swaps a
* pane already shown, or shows one from another tab in the target's place
* (the target gets a tab of its own); an edge places it on that side. */
export function dropAction(l: PaneLayout, src: number, zone: Zone): string {
if (zone !== "center") return `panes.drop.${zone}`;
return leaves(l.root).includes(src) ? "panes.drop.swap" : "panes.drop.replace";
}
/** A small anchored menu: a trigger button and a list that closes on a pick,
* an outside click or Escape. */
function PaneMenu({
@@ -398,6 +432,8 @@ export function PaneGrid({
const { t } = useTranslation("lanes");
const gridRef = useRef<HTMLDivElement>(null);
const [drop, setDrop] = useState<{ src: number; slot: number; zone: Zone } | null>(null);
// The slot a pane drag carries (a header here, or a tab from the bar).
const [dragging, setDragging] = useState<number | null>(null);
const { panes, dividers } = layoutRects(layout.root);
const shown = leaves(layout.root);
const rectOf = new Map(panes.map((p) => [p.slot, p]));
@@ -416,6 +452,7 @@ export function PaneGrid({
const end = () => {
dragSlot = null;
setDrop(null);
setDragging(null);
};
window.addEventListener("dragend", end);
window.addEventListener("drop", end);
@@ -479,6 +516,7 @@ export function PaneGrid({
if (!isPaneDrag(e)) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
if (dragSlot !== dragging) setDragging(dragSlot);
const z = zoneAt(e);
const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null;
setDrop((d) =>
@@ -495,6 +533,7 @@ export function PaneGrid({
const z = zoneAt(e);
dragSlot = null;
setDrop(null);
setDragging(null);
if (!z || !Number.isInteger(src) || src === z.slot) return;
onChange((l) => applyDrop(l, src, z.slot, z.zone));
}}
@@ -515,6 +554,7 @@ export function PaneGrid({
const r = rectOf.get(slot);
const run = runOf(slot);
const focused = layout.focus === slot;
const moving = dragging === slot;
return (
<div
key={key}
@@ -522,17 +562,23 @@ export function PaneGrid({
data-focused={r && focused ? "true" : undefined}
// A menu opened in this pane stays inside it (`menuRoom`).
data-menu-bounds
className={r ? "absolute flex flex-col" : "hidden"}
data-dragging={moving ? "true" : undefined}
className={r ? `absolute flex flex-col${moving ? " opacity-40" : ""}` : "hidden"}
style={r ? pct(r) : undefined}
onPointerDownCapture={() => r && focus(slot)}
// Tabbing into a pane focuses it as a click does.
onFocusCapture={() => r && focus(slot)}
>
{multi && r && (
{/* The dragged pane's header stays while the drag lasts, even
hidden in another tab: it is the drag's source node. */}
{((multi && r) || moving) && (
<div
data-testid={`pane-header-${slot}`}
draggable
onDragStart={(e) => startPaneDrag(e, slot)}
onDragStart={(e) => {
startPaneDrag(e, slot, run ? label(run) : t("panes.empty"));
setDragging(slot);
}}
onContextMenu={(e) => paneCtx.open(e, slot)}
title={t("panes.drag")}
className={`relative flex h-8 shrink-0 cursor-grab items-center gap-1 border-b border-border bg-surface-1 pl-1.5 pr-1 text-xs ${
@@ -667,12 +713,19 @@ export function PaneGrid({
}
/>
))}
{preview && (
{preview && drop && (
<div
data-testid="pane-drop-preview"
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
className="pointer-events-none absolute z-20 flex items-center justify-center rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
style={pct(preview)}
/>
>
<span
data-testid="pane-drop-label"
className="max-w-[90%] truncate rounded-md bg-accent-solid px-2.5 py-1 text-xs font-medium text-white shadow-lg"
>
{t(dropAction(layout, drop.src, drop.zone))}
</span>
</div>
)}
</div>
);
@@ -0,0 +1,126 @@
/**
* @file ConsoleTabs.test.tsx
* @description The tab bar's spring-loaded tabs: a pane drag resting on
* another tab shows it after `SPRING_MS`, so the pane can be dropped on that
* tab's panes; moving off before then, or resting on the tab already holding
* the pane, shows nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, createEvent, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import type { RunHandle } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
import { pane, split, type PaneGroups } from "../../../lib/paneLayout";
import { ConsoleTabs, SPRING_MS } from "../ConsoleTabs";
import { PANE_DRAG_TYPE, paneDragSlot } from "../PaneGrid";
vi.mock("../../../lib/api", () => ({
api: {
aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
},
},
}));
const LANE = { id: 1, tool: "claude", cwd: "/w", title: "w" } as unknown as Lane;
const run = (slot: number): RunHandle => ({
id: slot ? `agenthub-lane-1-${slot}` : "agenthub-lane-1",
laneId: 1,
slot,
status: "running",
cwd: "/w",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
tool: "shell",
});
// Tab 0: pane 0 alone (on screen); tab 1: panes 1 and 2.
const GROUPS: PaneGroups = {
groups: [
{ root: pane(0), focus: 0 },
{ root: split("row", pane(1), pane(2)), focus: 1 },
],
active: 0,
};
function setup() {
let groups = GROUPS;
const onGroupsChange = vi.fn((fn: (g: PaneGroups) => PaneGroups) => {
groups = fn(groups);
});
render(
<MemoryRouter>
<ConsoleTabs
lane={LANE}
tabs={[run(0), run(1), run(2)]}
groups={GROUPS}
onGroupsChange={onGroupsChange}
onOpen={vi.fn()}
onClosed={vi.fn()}
/>
</MemoryRouter>
);
// jsdom lays nothing out: tab i spans x 100i … 100i+100.
for (const el of document.querySelectorAll<HTMLElement>("[data-tab-index]")) {
const i = Number(el.dataset.tabIndex);
el.getBoundingClientRect = () =>
({
left: i * 100,
right: i * 100 + 100,
width: 100,
top: 0,
bottom: 40,
height: 40,
}) as DOMRect;
}
return { onGroupsChange, groups: () => groups };
}
const dataTransfer = () => ({
types: [PANE_DRAG_TYPE],
setData: vi.fn(),
getData: () => "",
effectAllowed: "",
dropEffect: "",
});
function drag(type: "dragStart" | "dragOver", el: Element, clientX = 0) {
const ev = createEvent[type](el, { dataTransfer: dataTransfer() });
Object.defineProperty(ev, "clientX", { value: clientX });
fireEvent(el, ev);
}
const tabEl = (key: string) => screen.getByTestId(`console-tab-${key}`).parentElement as Element;
describe("ConsoleTabs spring-loaded tabs", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("a pane drag resting on another tab shows that tab", () => {
const { onGroupsChange, groups } = setup();
drag("dragStart", tabEl("0"));
expect(paneDragSlot()).toBe(0);
drag("dragOver", tabEl("1-2"), 150);
act(() => void vi.advanceTimersByTime(SPRING_MS - 1));
expect(onGroupsChange).not.toHaveBeenCalled();
act(() => void vi.advanceTimersByTime(1));
expect(groups().active).toBe(1);
});
it("moving off the tab first, or resting on the pane's own tab, shows nothing", () => {
const { onGroupsChange } = setup();
drag("dragStart", tabEl("0"));
drag("dragOver", tabEl("1-2"), 150);
act(() => void vi.advanceTimersByTime(SPRING_MS / 2));
drag("dragOver", tabEl("0"), 50);
act(() => void vi.advanceTimersByTime(SPRING_MS * 2));
expect(onGroupsChange).not.toHaveBeenCalled();
});
});
@@ -2,12 +2,13 @@
* @file PaneGrid.test.ts
* @description The pane drop preview: it draws where the dragged pane will
* actually land (`dropPreview` over `applyDrop`), not half of the target's
* current box, and nothing for a drop that changes nothing.
* current box, and nothing for a drop that changes nothing; and the label
* naming what a drop does (`dropAction`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { dropPreview } from "../PaneGrid";
import { dropAction, dropPreview } from "../PaneGrid";
import { pane, split, type PaneLayout } from "../../../lib/paneLayout";
const AB: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
@@ -34,3 +35,15 @@ describe("dropPreview", () => {
expect(dropPreview(full, 5, 0, "left")).toBeNull();
});
});
describe("dropAction", () => {
it("an edge places the pane on that side", () => {
expect(dropAction(AB, 0, "left")).toBe("panes.drop.left");
expect(dropAction(AB, 2, "bottom")).toBe("panes.drop.bottom");
});
it("the center swaps a shown pane, or shows one from another tab in its place", () => {
expect(dropAction(AB, 0, "center")).toBe("panes.drop.swap");
expect(dropAction(AB, 2, "center")).toBe("panes.drop.replace");
});
});
@@ -2,7 +2,9 @@
* @file AccountSettings.tsx
* @description Settings → Account (server mode, any signed-in role): the
* account's own information (`GET /api/me/account` — username, role, member
* since, workspaces, sessions, active sign-ins, last sign-in), its display name
* since, workspaces, sessions, active sign-ins, last sign-in), its avatar
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB; the
* sidebar shows it at once), its display name
* and email (`PATCH /api/me/account`), a password change that asks for the
* current one (`POST /api/me/password` — this browser stays signed in, every
* other sign-in ends) and "Sign out other devices" (`POST
@@ -11,9 +13,17 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { LogOut, RefreshCw } from "lucide-react";
import { Camera, LogOut, RefreshCw, Trash2 } from "lucide-react";
import { api, type MyAccount } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
@@ -21,6 +31,7 @@ import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { useAuth } from "../AuthGate";
import { ROLE_BADGE } from "../users/UserEditDialog";
import { UserAvatar } from "../users/UserAvatar";
import { SettingsSection } from "./SettingsRow";
type ServerAccount = Extract<MyAccount, { mode: "server" }>;
@@ -42,8 +53,12 @@ export function AccountSettings() {
(r: MyAccount) => {
if (r.mode !== "server") return;
setAccount(r);
// The sidebar shows the display name: let it follow a save at once.
updateUser({ displayName: r.user.displayName, email: r.user.email });
// The sidebar shows the name and avatar: let them follow a save at once.
updateUser({
displayName: r.user.displayName,
email: r.user.email,
avatarAt: r.user.avatarAt ?? null,
});
},
[updateUser]
);
@@ -72,7 +87,7 @@ export function AccountSettings() {
return (
<div className="space-y-6">
<ProfileCard account={account} />
<ProfileCard account={account} onSaved={apply} />
<ProfileForm account={account} onSaved={apply} />
<PasswordForm />
<SignOutOthers logins={account.logins} onDone={load} />
@@ -80,19 +95,84 @@ export function AccountSettings() {
);
}
function ProfileCard({ account }: { account: ServerAccount }) {
/** The largest avatar the server takes (server/lib/avatars.js). */
const MAX_AVATAR_BYTES = 1024 * 1024;
function ProfileCard({
account,
onSaved,
}: {
account: ServerAccount;
onSaved: (r: MyAccount) => void;
}) {
const { t } = useTranslation("settings");
const { user } = account;
const shown = user.displayName || user.username;
const fileRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const run = async (op: () => Promise<MyAccount>, done: string) => {
setBusy(true);
try {
onSaved(await op());
toast.success(done);
} catch (e) {
toast.error(errText(t, e));
} finally {
setBusy(false);
}
};
const pick = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ""; // the same file can be picked again
if (!file) return;
if (file.size > MAX_AVATAR_BYTES) {
toast.error(t("account.avatar.tooBig"));
return;
}
void run(() => api.me.setAvatar(file), t("account.avatar.saved"));
};
return (
<section className="card p-5" data-testid="account-card">
<div className="flex items-center gap-4">
<span
aria-hidden
className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-lg font-semibold uppercase text-accent"
>
{shown.slice(0, 2)}
</span>
<div className="flex flex-shrink-0 flex-col items-center gap-1.5">
<UserAvatar name={shown} avatarAt={user.avatarAt} className="h-14 w-14 text-lg" />
<div className="flex items-center gap-0.5">
<button
type="button"
className="btn-ghost h-6 w-6 justify-center p-0"
onClick={() => fileRef.current?.click()}
disabled={busy}
title={t("account.avatar.change")}
aria-label={t("account.avatar.change")}
data-testid="account-avatar-change"
>
<Camera className="h-3.5 w-3.5" />
</button>
{user.avatarAt && (
<button
type="button"
className="btn-ghost h-6 w-6 justify-center p-0"
onClick={() => void run(() => api.me.removeAvatar(), t("account.avatar.removed"))}
disabled={busy}
title={t("account.avatar.remove")}
aria-label={t("account.avatar.remove")}
data-testid="account-avatar-remove"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
className="hidden"
onChange={pick}
data-testid="account-avatar-input"
/>
</div>
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-fg-primary">{shown}</h3>
<p className="truncate text-xs text-fg-muted">
@@ -0,0 +1,45 @@
/**
* @file ArtifactsHubSettings — Settings → AI tools → Artifacts Hub: the
* admin's server-wide switch that allows or blocks installing a shared skill
* or plugin from the Hub into a user's own account (`GET/PUT
* /api/artifacts/settings`). Renders nothing in local mode (there is no Hub)
* or for anyone the server does not let manage it (`canManage`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type ArtifactSettings } from "../../lib/api";
import { toast } from "../../lib/toast";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function ArtifactsHubSettings() {
const { t } = useTranslation("artifacts");
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
useEffect(() => {
api.artifacts
.settings()
.then(setSettings)
.catch(() => setSettings(null));
}, []);
if (!settings?.serverMode || !settings.canManage) return null;
return (
<SettingsSection id="artifacts-hub" title={t("title")}>
<SettingsSwitchRow
label={t("installsSwitch")}
description={t("installsSwitchHint")}
checked={settings.installsEnabled}
onChange={async (v) => {
try {
const r = await api.artifacts.setInstallsEnabled(v);
setSettings({ ...settings, installsEnabled: r.installsEnabled });
} catch (err) {
toast.error(err);
}
}}
/>
</SettingsSection>
);
}
@@ -2,8 +2,8 @@
* @file AccountSettings.test.tsx
* @description Settings → Account: shows the account's own information, saves
* only a changed display name/email, refuses a password change whose two new
* passwords differ, sends the current + new password, and asks before signing
* the other devices out.
* passwords differ, sends the current + new password, asks before signing
* the other devices out, and uploads/removes the avatar.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,6 +16,9 @@ const me = vi.hoisted(() => ({
updateAccount: vi.fn(),
changePassword: vi.fn(),
signOutOthers: vi.fn(),
setAvatar: vi.fn(),
removeAvatar: vi.fn(),
avatarUrl: (at?: string | null) => (at ? `/api/me/avatar?v=${at}` : null),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -44,7 +47,7 @@ const account = {
};
beforeEach(() => {
for (const fn of Object.values(me)) fn.mockReset();
for (const fn of Object.values(me)) if ("mockReset" in fn) fn.mockReset();
me.account.mockResolvedValue(account);
me.updateAccount.mockResolvedValue(account);
me.changePassword.mockResolvedValue({ ok: true });
@@ -53,6 +56,26 @@ beforeEach(() => {
});
describe("AccountSettings", () => {
it("uploads a picked avatar and removes it again", async () => {
const ue = userEvent.setup();
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-14T00:00:00Z" } };
me.setAvatar.mockResolvedValue(withAvatar);
me.removeAvatar.mockResolvedValue(account);
render(<AccountSettings />);
await screen.findByTestId("account-card");
expect(screen.getByTestId("user-avatar-initials").textContent).toBe("Mi");
expect(screen.queryByTestId("account-avatar-remove")).toBeNull();
const file = new File([new Uint8Array([0x89, 0x50])], "me.png", { type: "image/png" });
await ue.upload(screen.getByTestId("account-avatar-input"), file);
await waitFor(() => expect(me.setAvatar).toHaveBeenCalledWith(file));
expect((await screen.findByTestId("user-avatar-img")).getAttribute("src")).toBe(
"/api/me/avatar?v=2026-10-14T00:00:00Z"
);
await ue.click(screen.getByTestId("account-avatar-remove"));
await waitFor(() => expect(me.removeAvatar).toHaveBeenCalled());
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
});
it("shows the account's own information", async () => {
render(<AccountSettings />);
const card = await screen.findByTestId("account-card");
@@ -0,0 +1,46 @@
/**
* @file Tests for ArtifactsHubSettings — the admin's Hub install switch in
* Settings → AI tools: hidden in local mode and for a non-manager, and a
* toggle saves through PUT /api/artifacts/settings.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import "../../../i18n";
import { ArtifactsHubSettings } from "../ArtifactsHubSettings";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: { artifacts: { settings: vi.fn(), setInstallsEnabled: vi.fn() } },
}));
const settings = vi.mocked(api.artifacts.settings);
const setInstalls = vi.mocked(api.artifacts.setInstallsEnabled);
const base = { serverMode: true, canShare: true, installsEnabled: true, canManage: true };
describe("ArtifactsHubSettings", () => {
beforeEach(() => vi.clearAllMocks());
it("lets an admin turn Hub installs off", async () => {
settings.mockResolvedValue(base);
setInstalls.mockResolvedValue({ installsEnabled: false });
render(<ArtifactsHubSettings />);
const sw = await screen.findByRole("switch", { name: "Allow installing from the Hub" });
fireEvent.click(sw);
await waitFor(() => expect(setInstalls).toHaveBeenCalledWith(false));
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "false"));
});
it("renders nothing for a non-manager or in local mode", async () => {
settings.mockResolvedValue({ ...base, canManage: false });
const { container, unmount } = render(<ArtifactsHubSettings />);
await waitFor(() => expect(settings).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
unmount();
settings.mockResolvedValue({ ...base, serverMode: false });
const r = render(<ArtifactsHubSettings />);
await waitFor(() => expect(settings).toHaveBeenCalledTimes(2));
expect(r.container).toBeEmptyDOMElement();
});
});
@@ -2,7 +2,7 @@
* @file AccountPoolCard.tsx
* @description The Users page's account pool card (`GET /api/accounts`): the
* Claude and Codex accounts every user's new sessions are spread over, each
* with its live panes, $ in the last 5 h / 7 d and quota — so nobody signs in
* with its live panes, $ and tokens in the last 5 h / 7 d and quota — so nobody signs in
* per user. An admin adds one — for Claude a login MOVED in from a saved
* switch-user profile (`GET /api/accounts/profiles`), a pasted
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
@@ -23,7 +23,7 @@ import {
type PoolProfile,
type PoolTool,
} from "../../lib/api";
import { fmtCost, formatDateTime } from "../../lib/format";
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin";
@@ -40,6 +40,8 @@ type Row = {
livePanes: number;
spend5h: number;
spend7d: number;
tokens5h: number;
tokens7d: number;
quotaPercent: number | null;
full: boolean;
};
@@ -175,9 +177,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
{r.quotaPercent == null ? "—" : `${Math.round(r.quotaPercent)}%`}
</td>
<td className="px-5 py-2.5 text-right tabular-nums">
<div className="text-sm text-fg-primary">{fmtCost(r.spend5h)}</div>
<div className="text-sm text-fg-primary">
{fmtCost(r.spend5h)}
<span className="ml-1.5 text-[11px] text-fg-muted">
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
</span>
</div>
<div className="text-[11px] text-fg-muted">
{t("pool.spend7d", { cost: fmtCost(r.spend7d) })}
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
</div>
</td>
{isAdmin && (
@@ -0,0 +1,59 @@
/**
* @file UserAvatar.tsx
* @description An account's round picture: the signed-in account's own
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
* or when the image fails to load — the first letters of its name. Used by the
* sidebar's account row, its workspace owner groups and Settings → Account.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { api } from "../../lib/api";
export function UserAvatar({
name,
avatarAt,
userId,
letters = 2,
className = "h-7 w-7 text-[11px]",
initialsClassName = "bg-accent/15 text-accent",
}: {
name: string;
avatarAt?: string | null;
/** Another account's (staff only); omitted = the signed-in account's own. */
userId?: number;
/** How many letters of the name stand in for a missing picture. */
letters?: number;
/** Size and font size: the parts that differ between call sites. */
className?: string;
/** The initials' colours. */
initialsClassName?: string;
}) {
const url = userId != null ? api.users.avatarUrl(userId, avatarAt) : api.me.avatarUrl(avatarAt);
// A broken image falls back to the initials, per URL.
const [failed, setFailed] = useState<string | null>(null);
const base = `flex flex-shrink-0 items-center justify-center overflow-hidden rounded-full ${className}`;
if (url && failed !== url) {
return (
<img
src={url}
alt=""
aria-hidden
draggable={false}
onError={() => setFailed(url)}
className={`${base} object-cover`}
data-testid="user-avatar-img"
/>
);
}
return (
<span
aria-hidden
className={`${base} ${initialsClassName} font-semibold uppercase`}
data-testid="user-avatar-initials"
>
{name.slice(0, letters)}
</span>
);
}
@@ -40,6 +40,8 @@ const POOL: AccountPool = {
livePanes: 2,
spend5h: 1.5,
spend7d: 12,
tokens5h: 250_000,
tokens7d: 3_400_000,
quotaPercent: 42,
full: false,
},
@@ -52,6 +54,8 @@ const POOL: AccountPool = {
livePanes: 0,
spend5h: 0,
spend7d: 0,
tokens5h: 0,
tokens7d: 0,
quotaPercent: null,
full: false,
},
@@ -82,6 +86,8 @@ describe("AccountPoolCard", () => {
expect(await screen.findByText("team-1")).toBeInTheDocument();
expect(screen.getByText("cx-1")).toBeInTheDocument();
expect(screen.getByText("42%")).toBeInTheDocument();
expect(screen.getByText("250.0K tokens")).toBeInTheDocument();
expect(screen.getByText("$12.00 · 3.4M tokens in 7 days")).toBeInTheDocument();
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /add account/i })).toBeNull();
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();