Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59533b997b |
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# Claude Code Working Guide
|
||||||
|
|
||||||
_Last synced: v2.8.72 (2026-11-04) — an update puts back npm-rewritten lockfiles before its `--ff-only` merge; artifacts drag into folders._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
_Last synced: v2.8.73 (2026-11-04) — Claude quota rows carry `accountUuid`; the Artifacts folder bar is restyled._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||||
|
|
||||||
## Project mission
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
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 type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||||
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
|
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
|
||||||
import { AiToolIcon } from "./AiToolIcon";
|
import { AiToolIcon } from "./AiToolIcon";
|
||||||
|
import {
|
||||||
|
HIDDEN_PROFILES_EVENT,
|
||||||
|
isHiddenProfilesKey,
|
||||||
|
readHiddenProfiles,
|
||||||
|
standIn,
|
||||||
|
} from "../lib/hiddenProfilesStorage";
|
||||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
import { useSpin } from "../hooks/useSpin";
|
import { useSpin } from "../hooks/useSpin";
|
||||||
import { sessionChangeMatcher } from "../lib/sessionChange";
|
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. */
|
/** The right-end quota chips; nothing at all until a tool reports one. */
|
||||||
function QuotaChips() {
|
function QuotaChips() {
|
||||||
const { t } = useTranslation("nav");
|
const { t } = useTranslation("nav");
|
||||||
const [tools, setTools] = useState<ToolQuota[]>([]);
|
const [fetched, setTools] = useState<ToolQuota[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// Leaving the chips closes the panel only after a beat, and entering the
|
// Leaving the chips closes the panel only after a beat, and entering the
|
||||||
// panel cancels that: the pointer crosses the gap to read it.
|
// panel cancels that: the pointer crosses the gap to read it.
|
||||||
@@ -303,6 +343,8 @@ function QuotaChips() {
|
|||||||
);
|
);
|
||||||
const [refreshing, refresh] = useSpin(() => load(true));
|
const [refreshing, refresh] = useSpin(() => load(true));
|
||||||
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
||||||
|
const hidden = useHiddenProfiles();
|
||||||
|
const tools = useMemo(() => withoutHidden(fetched, hidden), [fetched, hidden]);
|
||||||
if (!tools.length) return null;
|
if (!tools.length) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ vi.mock("../../lib/api", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { StatusBar } from "../StatusBar";
|
import { StatusBar, withoutHidden } from "../StatusBar";
|
||||||
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
||||||
|
|
||||||
const at = (url: string, ui: React.JSX.Element) =>
|
const at = (url: string, ui: React.JSX.Element) =>
|
||||||
@@ -307,3 +307,35 @@ describe("StatusBar", () => {
|
|||||||
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
|
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 {
|
import {
|
||||||
profileKey,
|
profileKey,
|
||||||
readHiddenProfiles,
|
readHiddenProfiles,
|
||||||
|
standIn,
|
||||||
toggleHidden,
|
toggleHidden,
|
||||||
writeHiddenProfiles,
|
writeHiddenProfiles,
|
||||||
} from "../../lib/hiddenProfilesStorage";
|
} from "../../lib/hiddenProfilesStorage";
|
||||||
@@ -526,6 +527,17 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
|
|||||||
writeHiddenProfiles(next);
|
writeHiddenProfiles(next);
|
||||||
};
|
};
|
||||||
const activeHidden = !!active && !reveal && isHidden(active);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -622,7 +634,9 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
|
|||||||
<span>
|
<span>
|
||||||
{t("profiles.mismatch", {
|
{t("profiles.mismatch", {
|
||||||
config: mismatchEmail,
|
config: mismatchEmail,
|
||||||
owner: activeHidden ? "?" : active?.email || active?.accountUuid || "?",
|
owner: activeHidden
|
||||||
|
? decoy?.email || "?"
|
||||||
|
: active?.email || active?.accountUuid || "?",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -639,7 +653,16 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
|
|||||||
{t("profiles.activeLabel")}
|
{t("profiles.activeLabel")}
|
||||||
</div>
|
</div>
|
||||||
{active && activeHidden ? (
|
{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 ? (
|
) : active ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-sm text-fg-primary">
|
<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>
|
<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="pt-1.5 space-y-1.5">
|
||||||
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
|
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
|
||||||
<button
|
<button
|
||||||
@@ -673,7 +696,9 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
|
|||||||
{t("profiles.empty")}
|
{t("profiles.empty")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{items?.filter((p) => reveal || !isHidden(p)).map((p) => (
|
{items
|
||||||
|
?.filter((p) => reveal || !isHidden(p))
|
||||||
|
.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.name}
|
key={p.name}
|
||||||
data-testid={`profile-row-${p.name}`}
|
data-testid={`profile-row-${p.name}`}
|
||||||
@@ -695,13 +720,15 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{p.active && <ActiveBadge />}
|
{shownActive(p) && <ActiveBadge />}
|
||||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||||
{p.email || p.displayName || p.accountUuid}
|
{p.email || p.displayName || p.accountUuid}
|
||||||
</span>
|
</span>
|
||||||
{!p.active && (
|
{!shownActive(p) && (
|
||||||
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
|
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
|
||||||
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
|
{busy === `switch:${p.name}`
|
||||||
|
? t("profiles.switching")
|
||||||
|
: t("profiles.switchButton")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{pooled && <MoveToPool tool="claude" name={p.name} />}
|
{pooled && <MoveToPool tool="claude" name={p.name} />}
|
||||||
@@ -737,7 +764,7 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
|
|||||||
{ctx.menu && (
|
{ctx.menu && (
|
||||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-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>
|
<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)}>
|
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
|
||||||
{t("profiles.switchButton")}
|
{t("profiles.switchButton")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -160,6 +160,19 @@ describe("ToolProfilesSettings — Claude", () => {
|
|||||||
expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument();
|
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 () => {
|
it("does not hide anything in server mode", async () => {
|
||||||
perms.local = false;
|
perms.local = false;
|
||||||
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));
|
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));
|
||||||
|
|||||||
@@ -262,7 +262,7 @@
|
|||||||
"folders.delete": "Delete folder",
|
"folders.delete": "Delete folder",
|
||||||
"folders.deleteTitle": "Delete the folder “{{name}}”?",
|
"folders.deleteTitle": "Delete the folder “{{name}}”?",
|
||||||
"folders.deleteBody": "Only the folder goes; its artifacts stay where they are.",
|
"folders.deleteBody": "Only the folder goes; its artifacts stay where they are.",
|
||||||
"folders.empty": "This folder is empty. Put an artifact here with ⋯ → Move to folder.",
|
"folders.empty": "This folder is empty. Drag an artifact onto the folder, or use ⋯ → Move to folder.",
|
||||||
"folders.move": "Move to folder…",
|
"folders.move": "Move to folder…",
|
||||||
"folders.moveTitle": "Move “{{title}}” to a folder",
|
"folders.moveTitle": "Move “{{title}}” to a folder",
|
||||||
"folders.private": "Your folders are yours alone: nobody else sees them.",
|
"folders.private": "Your folders are yours alone: nobody else sees them.",
|
||||||
|
|||||||
@@ -260,7 +260,7 @@
|
|||||||
"folders.delete": "Xóa thư mục",
|
"folders.delete": "Xóa thư mục",
|
||||||
"folders.deleteTitle": "Xóa thư mục “{{name}}”?",
|
"folders.deleteTitle": "Xóa thư mục “{{name}}”?",
|
||||||
"folders.deleteBody": "Chỉ xóa thư mục; các artifact bên trong vẫn giữ nguyên.",
|
"folders.deleteBody": "Chỉ xóa thư mục; các artifact bên trong vẫn giữ nguyên.",
|
||||||
"folders.empty": "Thư mục này trống. Đưa artifact vào bằng ⋯ → Chuyển vào thư mục.",
|
"folders.empty": "Thư mục này trống. Kéo artifact thả vào thư mục, hoặc dùng ⋯ → Chuyển vào thư mục.",
|
||||||
"folders.move": "Chuyển vào thư mục…",
|
"folders.move": "Chuyển vào thư mục…",
|
||||||
"folders.moveTitle": "Chuyển “{{title}}” vào thư mục",
|
"folders.moveTitle": "Chuyển “{{title}}” vào thư mục",
|
||||||
"folders.private": "Thư mục là của riêng bạn: người khác không thấy.",
|
"folders.private": "Thư mục là của riêng bạn: người khác không thấy.",
|
||||||
|
|||||||
@@ -4,12 +4,17 @@
|
|||||||
* AI tools → Profiles, kept in localStorage with the same read/validate/
|
* AI tools → Profiles, kept in localStorage with the same read/validate/
|
||||||
* fallback convention as laneOrderStorage.ts. A viewer preference only: the
|
* fallback convention as laneOrderStorage.ts. A viewer preference only: the
|
||||||
* profile files, the API and the database are untouched, and another browser
|
* profile files, the API and the database are untouched, and another browser
|
||||||
* still lists every profile.
|
* still lists every profile. The status bar's Usage panel reads the same list
|
||||||
|
* (`HIDDEN_PROFILES_EVENT` tells it of a change in this tab), and when the
|
||||||
|
* live login is hidden both show the first visible profile in its place
|
||||||
|
* (`standIn`).
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STORAGE_KEY = "agenthub.cc.hiddenProfiles";
|
const STORAGE_KEY = "agenthub.cc.hiddenProfiles";
|
||||||
|
/** Fired on `window` after this tab changes the list. */
|
||||||
|
export const HIDDEN_PROFILES_EVENT = "agenthub:hidden-profiles";
|
||||||
|
|
||||||
/** A profile's stable key: its account (survives a rename), else its file name. */
|
/** A profile's stable key: its account (survives a rename), else its file name. */
|
||||||
export function profileKey(p: { accountUuid: string | null; name?: string }): string {
|
export function profileKey(p: { accountUuid: string | null; name?: string }): string {
|
||||||
@@ -34,6 +39,38 @@ export function writeHiddenProfiles(keys: string[]): void {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore quota / disabled storage */
|
/* ignore quota / disabled storage */
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(new Event(HIDDEN_PROFILES_EVENT));
|
||||||
|
} catch {
|
||||||
|
/* no window (tests outside jsdom) */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `key` names a change to the hidden list (a `storage` event's key). */
|
||||||
|
export const isHiddenProfilesKey = (key: string | null) => key === STORAGE_KEY || key === null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry shown in place of a hidden live login: the first one in `list`
|
||||||
|
* that is not hidden, or null when the live one is visible (or none is left).
|
||||||
|
*/
|
||||||
|
export function standIn<T extends { accountUuid?: string | null; name?: string }>(
|
||||||
|
list: T[],
|
||||||
|
isActive: (x: T) => boolean,
|
||||||
|
hidden: string[]
|
||||||
|
): T | null {
|
||||||
|
const live = list.find(isActive);
|
||||||
|
if (
|
||||||
|
!live ||
|
||||||
|
!hidden.includes(profileKey({ accountUuid: live.accountUuid ?? null, name: live.name }))
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return (
|
||||||
|
list.find(
|
||||||
|
(x) =>
|
||||||
|
!isActive(x) &&
|
||||||
|
!hidden.includes(profileKey({ accountUuid: x.accountUuid ?? null, name: x.name }))
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `keys` with `key` added when absent, removed when present. */
|
/** `keys` with `key` added when absent, removed when present. */
|
||||||
|
|||||||
@@ -1240,6 +1240,8 @@ export interface ToolQuota {
|
|||||||
active?: boolean;
|
active?: boolean;
|
||||||
/** The saved profile's label, when the account has one. */
|
/** The saved profile's label, when the account has one. */
|
||||||
label?: string | null;
|
label?: string | null;
|
||||||
|
/** Claude: the Anthropic account the row is about (hides a profile hidden in Settings). */
|
||||||
|
accountUuid?: string | null;
|
||||||
/** The signed-in account's email, when the tool's login names one. */
|
/** The signed-in account's email, when the tool's login names one. */
|
||||||
account?: string | null;
|
account?: string | null;
|
||||||
/** The subscription's name as the tool reports it (`pro`, `max`, `plus`, `team`…). */
|
/** The subscription's name as the tool reports it (`pro`, `max`, `plus`, `team`…). */
|
||||||
|
|||||||
@@ -65,9 +65,11 @@ import {
|
|||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
Search,
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -2564,13 +2566,24 @@ function FolderBar({
|
|||||||
if (active === f.id) onPick("");
|
if (active === f.id) onPick("");
|
||||||
};
|
};
|
||||||
const chip = (on: boolean) =>
|
const chip = (on: boolean) =>
|
||||||
`inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs whitespace-nowrap transition-colors ${
|
`inline-flex h-7 items-center gap-1.5 rounded-full border px-3 text-xs whitespace-nowrap transition-colors ${
|
||||||
on ? "bg-surface-4 text-fg-primary" : "text-fg-muted hover:text-fg-secondary"
|
on
|
||||||
|
? "border-accent/40 bg-accent-muted text-fg-primary"
|
||||||
|
: "border-border text-fg-secondary hover:border-border-light hover:bg-surface-2 hover:text-fg-primary"
|
||||||
}`;
|
}`;
|
||||||
const activeFolder = folders.find((f) => f.id === active);
|
const count = (n: number, on: boolean) => (
|
||||||
|
<span
|
||||||
|
className={`min-w-[1.25rem] rounded-full px-1.5 text-center text-[10px] leading-4 tabular-nums ${
|
||||||
|
on ? "bg-accent/20 text-accent" : "bg-surface-3 text-fg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const iconBtn =
|
||||||
|
"inline-flex h-7 w-7 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary";
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-1" data-testid="artifact-folders">
|
<div className="flex flex-wrap items-center gap-1.5" data-testid="artifact-folders">
|
||||||
<Folder className="mr-1 h-3.5 w-3.5 text-fg-muted" aria-hidden />
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={!active}
|
aria-pressed={!active}
|
||||||
@@ -2579,26 +2592,54 @@ function FolderBar({
|
|||||||
title={folders.length ? t("folders.dropOutHint") : undefined}
|
title={folders.length ? t("folders.dropOutHint") : undefined}
|
||||||
{...dropProps("")}
|
{...dropProps("")}
|
||||||
>
|
>
|
||||||
|
<Folder className="h-3.5 w-3.5" aria-hidden />
|
||||||
{t("folders.all")}
|
{t("folders.all")}
|
||||||
</button>
|
</button>
|
||||||
{folders.map((f) => (
|
{folders.map((f) => {
|
||||||
|
const on = active === f.id;
|
||||||
|
return editing === f.id ? null : (
|
||||||
|
<span key={f.id} className="inline-flex items-center">
|
||||||
<button
|
<button
|
||||||
key={f.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={active === f.id}
|
aria-pressed={on}
|
||||||
className={chip(active === f.id) + lit(f.id)}
|
className={chip(on) + lit(f.id)}
|
||||||
onClick={() => onPick(f.id)}
|
onClick={() => onPick(f.id)}
|
||||||
|
onDoubleClick={() => begin(f.id, f.name)}
|
||||||
title={t("folders.dropHint")}
|
title={t("folders.dropHint")}
|
||||||
data-testid={`artifact-folder-${f.id}`}
|
data-testid={`artifact-folder-${f.id}`}
|
||||||
{...dropProps(f.id)}
|
{...dropProps(f.id)}
|
||||||
>
|
>
|
||||||
{f.name}
|
<span className="max-w-[12rem] truncate">{f.name}</span>
|
||||||
<span className="text-fg-muted">{counts(f.id)}</span>
|
{count(counts(f.id), on)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
{on && !editing && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${iconBtn} ml-0.5`}
|
||||||
|
onClick={() => begin(f.id, f.name)}
|
||||||
|
title={t("folders.rename")}
|
||||||
|
aria-label={t("folders.rename")}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${iconBtn} hover:!text-status-danger`}
|
||||||
|
onClick={() => void remove(f)}
|
||||||
|
title={t("folders.delete")}
|
||||||
|
aria-label={t("folders.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<input
|
<input
|
||||||
className="input h-7 w-40 text-xs"
|
className="input h-7 w-44 rounded-full px-3 text-xs"
|
||||||
autoFocus
|
autoFocus
|
||||||
maxLength={MAX_FOLDER_NAME}
|
maxLength={MAX_FOLDER_NAME}
|
||||||
aria-label={t(editing === "new" ? "folders.newName" : "folders.rename")}
|
aria-label={t(editing === "new" ? "folders.newName" : "folders.rename")}
|
||||||
@@ -2619,7 +2660,7 @@ function FolderBar({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={chip(false)}
|
className="inline-flex h-7 items-center gap-1 rounded-full border border-dashed border-border px-3 text-xs text-fg-muted transition-colors hover:border-accent/50 hover:text-fg-primary"
|
||||||
onClick={() => begin("new")}
|
onClick={() => begin("new")}
|
||||||
data-testid="artifact-folder-new"
|
data-testid="artifact-folder-new"
|
||||||
>
|
>
|
||||||
@@ -2627,24 +2668,6 @@ function FolderBar({
|
|||||||
{t("folders.new")}
|
{t("folders.new")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{activeFolder && !editing && (
|
|
||||||
<span className="ml-1 flex gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-ghost px-2 py-1 text-xs"
|
|
||||||
onClick={() => begin(activeFolder.id, activeFolder.name)}
|
|
||||||
>
|
|
||||||
{t("folders.rename")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-ghost px-2 py-1 text-xs text-status-danger"
|
|
||||||
onClick={() => void remove(activeFolder)}
|
|
||||||
>
|
|
||||||
{t("folders.delete")}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.72 (rebuild client to match)
|
UI build v2.8.73 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -2961,7 +2961,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2
|
|||||||
GET /api/pricing/quota[?force=1]
|
GET /api/pricing/quota[?force=1]
|
||||||
```
|
```
|
||||||
|
|
||||||
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's as `GET /api/oauth/profile` reports it NOW (`organization.organization_type` / `rate_limit_tier` / `seat_tier`, remembered per account so saved profiles and pool accounts show it too), else read from the stored rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; the stored fields are written at sign-in, so they keep `pro` after an upgrade to Max), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
|
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, accountUuid?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. Every Claude row carries its Anthropic `accountUuid` (null when unknown). **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's as `GET /api/oauth/profile` reports it NOW (`organization.organization_type` / `rate_limit_tier` / `seat_tier`, remembered per account so saved profiles and pool accounts show it too), else read from the stored rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; the stored fields are written at sign-in, so they keep `pro` after an upgrade to Max), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
|
||||||
#### Analytics
|
#### Analytics
|
||||||
|
|
||||||
```http
|
```http
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.72",
|
"version": "2.8.73",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.72",
|
"version": "2.8.73",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.72",
|
"version": "2.8.73",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -281,6 +281,8 @@ test("the live row is the account its token belongs to, not the one .claude.json
|
|||||||
assert.strictEqual(live.label, "Victor");
|
assert.strictEqual(live.label, "Victor");
|
||||||
assert.strictEqual(live.account, "victor@x.y");
|
assert.strictEqual(live.account, "victor@x.y");
|
||||||
assert.strictEqual(live.ownerUuid, undefined);
|
assert.strictEqual(live.ownerUuid, undefined);
|
||||||
|
assert.strictEqual(live.accountUuid, "uuid-v");
|
||||||
|
assert.ok(claude.filter((t) => !t.active).every((t) => "accountUuid" in t));
|
||||||
assert.strictEqual(live.plan, "Max");
|
assert.strictEqual(live.plan, "Max");
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
claude.filter((t) => !t.active).map((t) => t.label),
|
claude.filter((t) => !t.active).map((t) => t.label),
|
||||||
|
|||||||
+14
-4
@@ -210,8 +210,8 @@ async function claudeQuotas(force, home) {
|
|||||||
liveClaude(() => memberClaudeAuth(home))
|
liveClaude(() => memberClaudeAuth(home))
|
||||||
);
|
);
|
||||||
if (!live) return [];
|
if (!live) return [];
|
||||||
const { ownerUuid: _uuid, ...shown } = live;
|
const { ownerUuid, ...shown } = live;
|
||||||
return [{ id: "claude", active: true, label: null, ...shown }];
|
return [{ id: "claude", active: true, label: null, accountUuid: ownerUuid || null, ...shown }];
|
||||||
}
|
}
|
||||||
let items = [];
|
let items = [];
|
||||||
try {
|
try {
|
||||||
@@ -231,12 +231,22 @@ async function claudeQuotas(force, home) {
|
|||||||
id: "claude",
|
id: "claude",
|
||||||
active: false,
|
active: false,
|
||||||
label: it.label,
|
label: it.label,
|
||||||
|
accountUuid: it.accountUuid || null,
|
||||||
...(await cached(`profile:${it.name}`, force, () => profileClaude(it))),
|
...(await cached(`profile:${it.name}`, force, () => profileClaude(it))),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
if (!live) return others;
|
if (!live) return others;
|
||||||
const { ownerUuid: _uuid, ...shown } = live;
|
const { ownerUuid, ...shown } = live;
|
||||||
return [{ id: "claude", active: true, label: mine?.label || null, ...shown }, ...others];
|
return [
|
||||||
|
{
|
||||||
|
id: "claude",
|
||||||
|
active: true,
|
||||||
|
label: mine?.label || null,
|
||||||
|
accountUuid: ownerUuid || mine?.accountUuid || null,
|
||||||
|
...shown,
|
||||||
|
},
|
||||||
|
...others,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The newest `rate_limits` in the last `TAIL_BYTES` of a rollout, or null. */
|
/** The newest `rate_limits` in the last `TAIL_BYTES` of a rollout, or null. */
|
||||||
|
|||||||
Reference in New Issue
Block a user