Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
6 changes: 5 additions & 1 deletion packages/i18n/src/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/i18n/src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 额度",
Expand Down
104 changes: 104 additions & 0 deletions pages/auth-files/__tests__/authFilesQuotaSort.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
53 changes: 53 additions & 0 deletions pages/auth-files/components/AuthFilesQuotaSortMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useTranslation } from "react-i18next";
import { Select } from "@code-proxy/ui";
import {
AUTH_FILES_SORT_MODES,
isAuthFilesSortMode,
useAuthFilesSortLoading,
useAuthFilesSortMode,
type AuthFilesSortMode,
} from "../hooks/useAuthFilesQuotaSort";

const MODE_LABEL_KEYS: Record<AuthFilesSortMode, string> = {
name: "auth_files.sort_by_name",
quota_asc: "auth_files.sort_quota_asc",
quota_desc: "auth_files.sort_quota_desc",
};

/**
* Sort control for the AI accounts list.
*
* 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();

return (
<div className="hidden lg:block" data-testid="auth-files-sort">
<Select
value={mode}
onChange={(value) => {
if (isAuthFilesSortMode(value)) setMode(value);
}}
options={AUTH_FILES_SORT_MODES.map((candidate) => ({
value: candidate,
label: t(MODE_LABEL_KEYS[candidate]),
}))}
aria-label={t("auth_files.sort_label")}
variant="chip"
size="sm"
className="min-w-[8.5rem]"
{...(loading ? { "data-loading": "true" } : {})}
/>
</div>
);
}
2 changes: 2 additions & 0 deletions pages/auth-files/components/AuthFilesToolbarActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -128,6 +129,7 @@ export function AuthFilesToolbarActions({
</Button>
</HoverTooltip>
{configActionsMenu}
<AuthFilesQuotaSortMenu />
{showCardColumns ? (
<div
className="hidden xl:block"
Expand Down
36 changes: 30 additions & 6 deletions pages/auth-files/hooks/useAuthFilesListState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type AuthFileStatusFilter,
} from "@code-proxy/domain";
import { isRuntimeOnlyAuthFile } from "@code-proxy/domain";
import { useAuthFilesQuotaSort } from "./useAuthFilesQuotaSort";

interface UseAuthFilesListStateOptions {
files: AuthFileItem[];
Expand All @@ -38,6 +39,10 @@ export function useAuthFilesListState({
selectedFileNames,
setSelectedFileNames,
}: UseAuthFilesListStateOptions) {
// Quota order has to be decided here, before the slice into pages, so the
// preference and its data are read here rather than threaded down from the
// page component.
const { mode: sortMode, ranks: quotaRanks } = useAuthFilesQuotaSort(files);
const providerOptions = useMemo(() => {
const set = new Set<string>();
files.forEach((file) => set.add(resolveFileType(file)));
Expand Down Expand Up @@ -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));
Expand Down
Binary file added pages/auth-files/hooks/useAuthFilesQuotaSort.ts
Binary file not shown.
Loading