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