From 6307841d57161525e911de42ccfc0eb022ce82a7 Mon Sep 17 00:00:00 2001 From: kittors Date: Wed, 19 Aug 2026 23:05:12 +0800 Subject: [PATCH 1/2] feat(ai-accounts): sort accounts by remaining quota MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AI accounts list could only be ordered by name. With many accounts, finding one that still has headroom meant reading every card. Adds a sort control to the toolbar: by name (default), least quota left first, most quota left first. An account's position is decided by the tightest quota its card actually shows. Scoping it to the visible slots matters: antigravity reports both a 5h and a weekly bucket but the card renders only the 5h one, and ranking by a number the operator cannot see reads as a broken sort. Sorting pulls the full status snapshot once. The list loads status only for the accounts on the current page — correct for rendering, useless for ordering, since sorting a page by numbers only that page has just shuffles it. The endpoint already returns every account when no auth_index filter is given, so no backend change was needed. The order is applied before the slice into pages. Accounts with no reading sort last in both directions and keep name order among themselves. Unknown is not empty and not full; floating it to the top of either direction would bury what the operator opened the view to find. A failed snapshot leaves everything unranked, which preserves name order rather than presenting a partial order as authoritative. The preference and the fetch state are module-scoped and the preference is persisted. AuthFilesPage.tsx and AuthFilesFilesTab.tsx are both frozen at their size baselines, so threading this through as props was not available; the list and the toolbar control read the shared value independently. Neither file is touched by this change. Co-Authored-By: Claude Opus 5 --- packages/i18n/src/locales/en.json | 4 + packages/i18n/src/locales/ru.json | 6 +- packages/i18n/src/locales/zh-CN.json | 6 +- .../__tests__/authFilesQuotaSort.test.ts | 104 ++++++++++++++++++ .../components/AuthFilesQuotaSortMenu.tsx | 75 +++++++++++++ .../components/AuthFilesToolbarActions.tsx | 2 + .../auth-files/hooks/useAuthFilesListState.ts | 36 +++++- .../auth-files/hooks/useAuthFilesQuotaSort.ts | Bin 0 -> 7766 bytes 8 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 pages/auth-files/__tests__/authFilesQuotaSort.test.ts create mode 100644 pages/auth-files/components/AuthFilesQuotaSortMenu.tsx create mode 100644 pages/auth-files/hooks/useAuthFilesQuotaSort.ts diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index f561f565..1114119a 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -485,6 +485,10 @@ "search_empty_desc": "Try a different keyword or clear the search box" }, "auth_files": { + "sort_label": "Sort accounts", + "sort_by_name": "By name", + "sort_quota_asc": "Least quota left first", + "sort_quota_desc": "Most quota left first", "title": "AI Accounts", "title_section": "AI Accounts", "description": "Manage OAuth logins and auth credentials for AI platforms, including usage, quotas, and identity fingerprints.", diff --git a/packages/i18n/src/locales/ru.json b/packages/i18n/src/locales/ru.json index 6c6a5802..737ce7b3 100644 --- a/packages/i18n/src/locales/ru.json +++ b/packages/i18n/src/locales/ru.json @@ -879,7 +879,11 @@ "fork": "Сохранить оригинал", "fork_label": "Сохранить оригинал", "fork_hint": "Если включено, доступны и исходное имя модели, и псевдоним. Если выключено, наружу виден только псевдоним.", - "cycle_tokens_count": "Токены за цикл {{value}}" + "cycle_tokens_count": "Токены за цикл {{value}}", + "sort_label": "Сортировка аккаунтов", + "sort_by_name": "По имени", + "sort_quota_asc": "Сначала с наименьшим остатком", + "sort_quota_desc": "Сначала с наибольшим остатком" }, "antigravity_quota": { "title": "Квота Antigravity", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index 54b4258e..dd7163e3 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -1016,7 +1016,11 @@ "no_tags": "暂无标签", "hide_tag": "隐藏 {{tag}}", "restore_tag": "恢复 {{tag}}", - "remove_custom_tag": "移除 {{tag}}" + "remove_custom_tag": "移除 {{tag}}", + "sort_label": "账号排序", + "sort_by_name": "按名称", + "sort_quota_asc": "剩余额度少的在前", + "sort_quota_desc": "剩余额度多的在前" }, "antigravity_quota": { "title": "Antigravity 额度", diff --git a/pages/auth-files/__tests__/authFilesQuotaSort.test.ts b/pages/auth-files/__tests__/authFilesQuotaSort.test.ts new file mode 100644 index 00000000..df754cc4 --- /dev/null +++ b/pages/auth-files/__tests__/authFilesQuotaSort.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "vitest"; +import type { AuthFileItem } from "@code-proxy/api-client"; +import { + isAuthFilesSortMode, + resolveAuthFileQuotaRank, +} from "../hooks/useAuthFilesQuotaSort"; +import type { QuotaItem } from "@features/quota-preview/quota-types"; + +const file = (provider: string): AuthFileItem => + ({ name: `${provider}.json`, provider, type: provider }) as AuthFileItem; + +describe("resolveAuthFileQuotaRank", () => { + // The card shows one row per Codex window; the tightest of them decides the + // account's position, because that is the one that will refuse a request next. + test("ranks a codex account by its tightest visible window", () => { + const items: QuotaItem[] = [ + { key: "code_5h", label: "m_quota.code_5h", percent: 12 }, + { key: "code_week", label: "m_quota.code_weekly", percent: 80 }, + ]; + expect(resolveAuthFileQuotaRank(file("codex"), items)).toBe(12); + }); + + // Antigravity reports a weekly bucket too, but the card renders only the 5h + // one. Ranking by a number the operator cannot see reads as a broken sort. + test("ignores windows the antigravity card does not show", () => { + const items: QuotaItem[] = [ + { + key: "antigravity:gemini_5h", + label: "Gemini Models · 5h", + percent: 72, + windowSeconds: 5 * 60 * 60, + }, + { + key: "antigravity:gemini_weekly", + label: "Gemini Models · weekly", + percent: 3, + windowSeconds: 7 * 24 * 60 * 60, + }, + ]; + expect(resolveAuthFileQuotaRank(file("antigravity"), items)).toBe(72); + }); + + test("is unknown when no visible window carries a number", () => { + expect(resolveAuthFileQuotaRank(file("codex"), [])).toBeNull(); + expect( + resolveAuthFileQuotaRank(file("codex"), [ + { key: "code_5h", label: "m_quota.code_5h", percent: null }, + ]), + ).toBeNull(); + }); + + test("is unknown for a file with no quota provider", () => { + expect( + resolveAuthFileQuotaRank(file("unknown-provider"), [ + { key: "whatever", label: "whatever", percent: 40 }, + ]), + ).toBeNull(); + }); +}); + +describe("isAuthFilesSortMode", () => { + test("accepts known modes only", () => { + expect(isAuthFilesSortMode("name")).toBe(true); + expect(isAuthFilesSortMode("quota_asc")).toBe(true); + expect(isAuthFilesSortMode("quota_desc")).toBe(true); + expect(isAuthFilesSortMode("quota")).toBe(false); + expect(isAuthFilesSortMode(undefined)).toBe(false); + }); +}); + +describe("useAuthFilesSortMode", () => { + test("defaults to name order and shares the choice across instances", async () => { + const { renderHook, act } = await import("@testing-library/react"); + const { useAuthFilesSortMode, resetAuthFilesSortModeForTests } = await import( + "../hooks/useAuthFilesQuotaSort" + ); + localStorage.clear(); + resetAuthFilesSortModeForTests(); + + const first = renderHook(() => useAuthFilesSortMode()); + const second = renderHook(() => useAuthFilesSortMode()); + expect(first.result.current.mode).toBe("name"); + + // The list and the toolbar control read this independently; if they held + // separate state the control would move while the list stayed put. + act(() => first.result.current.setMode("quota_asc")); + expect(second.result.current.mode).toBe("quota_asc"); + + resetAuthFilesSortModeForTests(); + const reopened = renderHook(() => useAuthFilesSortMode()); + expect(reopened.result.current.mode).toBe("quota_asc"); + }); + + test("ignores an unrecognised stored value", async () => { + const { renderHook } = await import("@testing-library/react"); + const { useAuthFilesSortMode, resetAuthFilesSortModeForTests } = await import( + "../hooks/useAuthFilesQuotaSort" + ); + localStorage.setItem("auth-files:sort-mode", "by_feel"); + resetAuthFilesSortModeForTests(); + const { result } = renderHook(() => useAuthFilesSortMode()); + expect(result.current.mode).toBe("name"); + }); +}); diff --git a/pages/auth-files/components/AuthFilesQuotaSortMenu.tsx b/pages/auth-files/components/AuthFilesQuotaSortMenu.tsx new file mode 100644 index 00000000..c4ba3a9d --- /dev/null +++ b/pages/auth-files/components/AuthFilesQuotaSortMenu.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from "react-i18next"; +import { ArrowDownWideNarrow, ArrowUpNarrowWide, ArrowUpAZ, Loader2 } from "lucide-react"; +import { Button, DropdownMenu } from "@code-proxy/ui"; +import { + AUTH_FILES_SORT_MODES, + useAuthFilesSortLoading, + useAuthFilesSortMode, + type AuthFilesSortMode, +} from "../hooks/useAuthFilesQuotaSort"; + +const MODE_LABEL_KEYS: Record = { + name: "auth_files.sort_by_name", + quota_asc: "auth_files.sort_quota_asc", + quota_desc: "auth_files.sort_quota_desc", +}; + +const MODE_ICONS: Record = { + name: ArrowUpAZ, + quota_asc: ArrowUpNarrowWide, + quota_desc: ArrowDownWideNarrow, +}; + +/** + * Sort control for the AI accounts list. + * + * Reads the shared preference rather than taking it as a prop: both the page + * component and the files tab are frozen at their size baselines, and the list + * reads the same value independently to order accounts ahead of pagination. + */ +export function AuthFilesQuotaSortMenu() { + const { t } = useTranslation(); + const { mode, setMode } = useAuthFilesSortMode(); + const loading = useAuthFilesSortLoading(); + const ActiveIcon = MODE_ICONS[mode]; + + return ( + + + + + + + {AUTH_FILES_SORT_MODES.map((candidate) => { + const Icon = MODE_ICONS[candidate]; + return ( + setMode(candidate)} + data-testid={`auth-files-sort-${candidate}`} + > + + + {t(MODE_LABEL_KEYS[candidate])} + + + ); + })} + + + + ); +} diff --git a/pages/auth-files/components/AuthFilesToolbarActions.tsx b/pages/auth-files/components/AuthFilesToolbarActions.tsx index e1938e5c..a6254f25 100644 --- a/pages/auth-files/components/AuthFilesToolbarActions.tsx +++ b/pages/auth-files/components/AuthFilesToolbarActions.tsx @@ -9,6 +9,7 @@ import { Upload, } from "lucide-react"; import { Button, HoverTooltip, Select } from "@code-proxy/ui"; +import { AuthFilesQuotaSortMenu } from "./AuthFilesQuotaSortMenu"; export type AuthFilesToolbarActionsProps = { t: TFunction; @@ -128,6 +129,7 @@ export function AuthFilesToolbarActions({ {configActionsMenu} + {showCardColumns ? (
{ const set = new Set(); files.forEach((file) => set.add(resolveFileType(file))); @@ -107,12 +112,31 @@ export function useAuthFilesListState({ authFileMatchesStatusFilter(file, statusFilter), ); const searchFilteredNames = new Set(searchFilteredFiles.map((file) => file.name)); - return statusScoped - .filter((file) => searchFilteredNames.has(file.name)) - .sort((a, b) => - authFilesSortCollator.compare(resolveAuthFileSortKey(a), resolveAuthFileSortKey(b)), - ); - }, [searchFilteredFiles, statusFilter, tagScopedFiles]); + const byName = (a: AuthFileItem, b: AuthFileItem) => + authFilesSortCollator.compare(resolveAuthFileSortKey(a), resolveAuthFileSortKey(b)); + const scoped = statusScoped.filter((file) => searchFilteredNames.has(file.name)); + + if (sortMode === "name") return scoped.sort(byName); + + // Quota order is applied here, ahead of the slice into pages: sorting after + // pagination would only rearrange whichever accounts happened to land on the + // current page, which is not an order at all. + // + // Accounts with no reading fall to the end in both directions and keep name + // order among themselves. Unknown is not empty and not full, and floating it + // to the top of either direction would bury exactly what the operator opened + // this view to find. + const direction = sortMode === "quota_asc" ? 1 : -1; + return scoped.sort((a, b) => { + const left = quotaRanks?.[a.name] ?? null; + const right = quotaRanks?.[b.name] ?? null; + if (left === null && right === null) return byName(a, b); + if (left === null) return 1; + if (right === null) return -1; + if (left === right) return byName(a, b); + return (left - right) * direction; + }); + }, [searchFilteredFiles, statusFilter, tagScopedFiles, quotaRanks, sortMode]); const totalPages = Math.max(1, Math.ceil(filteredFiles.length / pageSize)); const safePage = Math.min(totalPages, Math.max(1, page)); diff --git a/pages/auth-files/hooks/useAuthFilesQuotaSort.ts b/pages/auth-files/hooks/useAuthFilesQuotaSort.ts new file mode 100644 index 0000000000000000000000000000000000000000..841f438f90ffd42b6b646ecc0406b028564556d4 GIT binary patch literal 7766 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 Rtun?=XBBFKkkPfl`X8{5B3A$a literal 0 HcmV?d00001 From 430535d9c31d3f6798865b278517e30680b44344 Mon Sep 17 00:00:00 2001 From: kittors Date: Wed, 19 Aug 2026 23:20:46 +0800 Subject: [PATCH 2/2] refactor(ai-accounts): use the toolbar's Select for the sort control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the column-count control it sits beside — same affordance for the same kind of choice, and no second popover implementation on a toolbar that already has one. Co-Authored-By: Claude Opus 5 --- .../components/AuthFilesQuotaSortMenu.tsx | 76 +++++++------------ 1 file changed, 27 insertions(+), 49 deletions(-) diff --git a/pages/auth-files/components/AuthFilesQuotaSortMenu.tsx b/pages/auth-files/components/AuthFilesQuotaSortMenu.tsx index c4ba3a9d..566fc788 100644 --- a/pages/auth-files/components/AuthFilesQuotaSortMenu.tsx +++ b/pages/auth-files/components/AuthFilesQuotaSortMenu.tsx @@ -1,8 +1,8 @@ import { useTranslation } from "react-i18next"; -import { ArrowDownWideNarrow, ArrowUpNarrowWide, ArrowUpAZ, Loader2 } from "lucide-react"; -import { Button, DropdownMenu } from "@code-proxy/ui"; +import { Select } from "@code-proxy/ui"; import { AUTH_FILES_SORT_MODES, + isAuthFilesSortMode, useAuthFilesSortLoading, useAuthFilesSortMode, type AuthFilesSortMode, @@ -14,62 +14,40 @@ const MODE_LABEL_KEYS: Record = { quota_desc: "auth_files.sort_quota_desc", }; -const MODE_ICONS: Record = { - name: ArrowUpAZ, - quota_asc: ArrowUpNarrowWide, - quota_desc: ArrowDownWideNarrow, -}; - /** * Sort control for the AI accounts list. * - * Reads the shared preference rather than taking it as a prop: both the page - * component and the files tab are frozen at their size baselines, and the list - * reads the same value independently to order accounts ahead of pagination. + * Reads the shared preference rather than taking it as a prop: both + * AuthFilesPage and AuthFilesFilesTab are frozen at their size baselines, and + * the list reads the same value independently to order accounts ahead of + * pagination. + * + * A Select rather than a dropdown menu, matching the column-count control it + * sits beside — same affordance for the same kind of choice, and no second + * popover implementation on this toolbar. */ export function AuthFilesQuotaSortMenu() { const { t } = useTranslation(); const { mode, setMode } = useAuthFilesSortMode(); const loading = useAuthFilesSortLoading(); - const ActiveIcon = MODE_ICONS[mode]; return ( - - - - - - - {AUTH_FILES_SORT_MODES.map((candidate) => { - const Icon = MODE_ICONS[candidate]; - return ( - setMode(candidate)} - data-testid={`auth-files-sort-${candidate}`} - > - - - {t(MODE_LABEL_KEYS[candidate])} - - - ); - })} - - - +
+