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