chore: v2.8.73 — Artifacts folder bar restyled; hidden live profile gets a stand-in

- Artifacts: folders are rounded chips with a count badge; rename/delete are
  icons beside the picked folder (double-click renames); New folder is a
  dashed chip; the empty-folder hint mentions drag and drop.
- GET /api/pricing/quota: every Claude row carries its accountUuid.
- Settings → Profiles and the status bar's Usage panel: a profile hidden in
  this browser is left out of the Usage panel too; when the live login is
  hidden, the first visible profile is shown in its place (Logged in now,
  "in use", the % used chip with that profile's own usage). Display only.
This commit is contained in:
2026-11-04 16:14:27 +07:00
parent e21120d51d
commit 59533b997b
16 changed files with 305 additions and 117 deletions
+44 -2
View File
@@ -29,7 +29,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router-dom";
@@ -38,6 +38,12 @@ import { api, type RunHandle, type UserQuotaStatus } from "../lib/api";
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import {
HIDDEN_PROFILES_EVENT,
isHiddenProfilesKey,
readHiddenProfiles,
standIn,
} from "../lib/hiddenProfilesStorage";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { sessionChangeMatcher } from "../lib/sessionChange";
@@ -276,10 +282,44 @@ function QuotaPanel({
);
}
/** The profiles hidden in Settings → AI tools → Profiles, kept current. */
function useHiddenProfiles(): string[] {
const [hidden, setHidden] = useState<string[]>(readHiddenProfiles);
useEffect(() => {
const again = () => setHidden(readHiddenProfiles());
const onStorage = (e: StorageEvent) => isHiddenProfilesKey(e.key) && again();
window.addEventListener(HIDDEN_PROFILES_EVENT, again);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(HIDDEN_PROFILES_EVENT, again);
window.removeEventListener("storage", onStorage);
};
}, []);
return hidden;
}
/**
* Claude rows without the hidden profiles. When the live login is one of
* them, the first visible profile stands in as the live row — its own usage
* on the chip and the panel — so the panel never names the hidden account.
*/
export function withoutHidden(tools: ToolQuota[], hidden: string[]): ToolQuota[] {
if (!hidden.length) return tools;
const claude = tools.filter((q) => q.id === "claude");
const isLive = (q: ToolQuota) => q.active !== false;
const decoy = standIn(claude, isLive, hidden);
const keep = (q: ToolQuota) =>
q.id !== "claude" || !q.accountUuid || !hidden.includes(q.accountUuid);
const shown = tools.filter(keep);
if (!decoy) return shown;
// The stand-in leads the Claude rows, as the live login does.
return [{ ...decoy, active: true }, ...shown.filter((q) => q !== decoy)];
}
/** The right-end quota chips; nothing at all until a tool reports one. */
function QuotaChips() {
const { t } = useTranslation("nav");
const [tools, setTools] = useState<ToolQuota[]>([]);
const [fetched, setTools] = useState<ToolQuota[]>([]);
const [open, setOpen] = useState(false);
// Leaving the chips closes the panel only after a beat, and entering the
// panel cancels that: the pointer crosses the gap to read it.
@@ -303,6 +343,8 @@ function QuotaChips() {
);
const [refreshing, refresh] = useSpin(() => load(true));
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
const hidden = useHiddenProfiles();
const tools = useMemo(() => withoutHidden(fetched, hidden), [fetched, hidden]);
if (!tools.length) return null;
return (
<>
@@ -162,7 +162,7 @@ vi.mock("../../lib/api", () => ({
}));
import { api } from "../../lib/api";
import { StatusBar } from "../StatusBar";
import { StatusBar, withoutHidden } from "../StatusBar";
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
const at = (url: string, ui: React.JSX.Element) =>
@@ -307,3 +307,35 @@ describe("StatusBar", () => {
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
});
});
describe("withoutHidden", () => {
const row = (accountUuid: string, active: boolean, pct: number) => ({
id: "claude" as const,
active,
accountUuid,
account: `${accountUuid}@x.y`,
windows: [{ label: "5h", usedPercent: pct, resetsAt: null }],
});
const codex = { id: "codex" as const, active: true, windows: [] };
it("leaves the list alone when nothing is hidden", () => {
const list = [row("u1", true, 82), row("u2", false, 19), codex];
expect(withoutHidden(list, [])).toBe(list);
});
it("drops a hidden saved profile", () => {
const out = withoutHidden([row("u1", true, 82), row("u2", false, 19), codex], ["u2"]);
expect(out.map((q) => q.accountUuid ?? q.id)).toEqual(["u1", "codex"]);
});
it("puts the first visible profile, with its own usage, in place of a hidden live login", () => {
const out = withoutHidden(
[row("u1", true, 82), row("u2", false, 19), row("u3", false, 0), codex],
["u1"]
);
expect(out.map((q) => q.accountUuid ?? q.id)).toEqual(["u2", "u3", "codex"]);
expect(out[0]?.active).toBe(true);
expect(out[0]?.windows[0]?.usedPercent).toBe(19);
expect(out[1]?.active).toBe(false);
});
});
@@ -41,6 +41,7 @@ import { toast } from "../../lib/toast";
import {
profileKey,
readHiddenProfiles,
standIn,
toggleHidden,
writeHiddenProfiles,
} from "../../lib/hiddenProfilesStorage";
@@ -526,6 +527,17 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
writeHiddenProfiles(next);
};
const activeHidden = !!active && !reveal && isHidden(active);
/** Shown as the live login while the real one is hidden: the first visible
* profile, with its own usage — a "—" there would only draw the eye. */
const decoy =
activeHidden && items
? standIn(
items.map((p) => ({ ...p, accountUuid: p.accountUuid ?? null })),
(p) => p.active || (!!active?.accountUuid && p.accountUuid === active.accountUuid),
hidden
)
: null;
const shownActive = (p: CcProfile) => (decoy ? p.name === decoy.name : p.active);
const load = useCallback(async () => {
try {
@@ -622,7 +634,9 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
<span>
{t("profiles.mismatch", {
config: mismatchEmail,
owner: activeHidden ? "?" : active?.email || active?.accountUuid || "?",
owner: activeHidden
? decoy?.email || "?"
: active?.email || active?.accountUuid || "?",
})}
</span>
</p>
@@ -639,7 +653,16 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
{t("profiles.activeLabel")}
</div>
{active && activeHidden ? (
<div className="text-sm text-fg-muted">—</div>
decoy ? (
<>
<div className="text-sm text-fg-primary">
{decoy.email || decoy.displayName || decoy.accountUuid}
</div>
<IdentityLine identity={decoy} />
</>
) : (
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
)
) : active ? (
<>
<div className="text-sm text-fg-primary">
@@ -650,7 +673,7 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
) : (
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
)}
{active && !activeSaved && (
{active && !activeSaved && !activeHidden && (
<div className="pt-1.5 space-y-1.5">
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
<button
@@ -673,71 +696,75 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
{t("profiles.empty")}
</div>
)}
{items?.filter((p) => reveal || !isHidden(p)).map((p) => (
<div
key={p.name}
data-testid={`profile-row-${p.name}`}
onContextMenu={(e) => ctx.open(e, p)}
onClickCapture={(e) => toggleRow(e, p)}
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
>
<div className="flex items-center gap-2 flex-wrap">
<LabelEdit
label={p.label}
busy={busy !== null}
editing={renaming === p.name}
setEditing={(on) => setRenaming(on ? p.name : null)}
onRename={(next) =>
void run(
`rename:${p.name}`,
() => api.ccConfig.renameProfile(p.name, next),
t("profiles.renameSuccess", { name: next })
)
}
{items
?.filter((p) => reveal || !isHidden(p))
.map((p) => (
<div
key={p.name}
data-testid={`profile-row-${p.name}`}
onContextMenu={(e) => ctx.open(e, p)}
onClickCapture={(e) => toggleRow(e, p)}
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
>
<div className="flex items-center gap-2 flex-wrap">
<LabelEdit
label={p.label}
busy={busy !== null}
editing={renaming === p.name}
setEditing={(on) => setRenaming(on ? p.name : null)}
onRename={(next) =>
void run(
`rename:${p.name}`,
() => api.ccConfig.renameProfile(p.name, next),
t("profiles.renameSuccess", { name: next })
)
}
/>
{shownActive(p) && <ActiveBadge />}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{p.email || p.displayName || p.accountUuid}
</span>
{!shownActive(p) && (
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
{busy === `switch:${p.name}`
? t("profiles.switching")
: t("profiles.switchButton")}
</button>
)}
{pooled && <MoveToPool tool="claude" name={p.name} />}
{deletable(p) && (
<button
type="button"
disabled={busy !== null}
onClick={() => void remove(p)}
title={t("profiles.deleteButton")}
aria-label={t("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<IdentityLine identity={p} />
<ProfileUsageLine
usage={usage[p.name]}
error={usageError[p.name]}
busy={!!usageBusy[p.name]}
disabled={busy !== null || !!usageBusy[p.name]}
onCheck={() => void checkUsage(p.name)}
/>
{p.active && <ActiveBadge />}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{p.email || p.displayName || p.accountUuid}
</span>
{!p.active && (
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
</button>
)}
{pooled && <MoveToPool tool="claude" name={p.name} />}
{deletable(p) && (
<button
type="button"
disabled={busy !== null}
onClick={() => void remove(p)}
title={t("profiles.deleteButton")}
aria-label={t("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
</div>
)}
</div>
<IdentityLine identity={p} />
<ProfileUsageLine
usage={usage[p.name]}
error={usageError[p.name]}
busy={!!usageBusy[p.name]}
disabled={busy !== null || !!usageBusy[p.name]}
onCheck={() => void checkUsage(p.name)}
/>
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
</div>
)}
</div>
))}
))}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
<MenuItem onSelect={pick((p) => setRenaming(p.name))}>{t("profiles.rename")}</MenuItem>
{!ctx.menu.item.active && (
{!shownActive(ctx.menu.item) && !ctx.menu.item.active && (
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
{t("profiles.switchButton")}
</MenuItem>
@@ -160,6 +160,19 @@ describe("ToolProfilesSettings — Claude", () => {
expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument();
});
it("shows the first visible profile as the live login while the live one is hidden", async () => {
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u1"]));
await show();
await settle();
expect(screen.queryByTestId("profile-row-work")).toBeNull();
expect(screen.queryByText("a@b.c")).toBeNull();
expect(screen.queryByText("—")).toBeNull();
const spare = screen.getByTestId("profile-row-spare");
expect(spare).toHaveTextContent(/active/);
expect(screen.queryByRole("button", { name: "Switch to" })).toBeNull();
expect(screen.getAllByText("d@e.f").length).toBe(2);
});
it("does not hide anything in server mode", async () => {
perms.local = false;
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));