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
@@ -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");
});