Skip to content
Open
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
42 changes: 42 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# CPA-Manager Fork Context

This context defines the product language for a CPA-Manager fork that selectively absorbs LTS quota capabilities.

## Language

**CPA-Manager Fork**:
The product line based on seakee/CPA-Manager that receives selected capabilities from CPA Panel LTS.
_Avoid_: CPA Panel LTS, ordinary upstream panel

**CPA Panel LTS**:
The source product line for selected quota capabilities that should be ported into the CPA-Manager Fork.
_Avoid_: target product, replacement manager

**Usage Service**:
The companion CPA-Manager service that owns persistent request usage for newer CPA deployments.
_Avoid_: optional stats add-on, replacement CPA runtime

**Weekly Quota Estimate**:
The Codex quota capability that estimates or prioritizes weekly quota limits when presenting account usage.
_Avoid_: generic quota display, daily-only usage

**Single Credential Refresh**:
The quota interaction that refreshes one credential without forcing a full credential-list refresh.
_Avoid_: global refresh, bulk refresh

## Relationships

- **CPA-Manager Fork** is based on CPA-Manager, not on **CPA Panel LTS**.
- **CPA-Manager Fork** uses **Usage Service** for persistent request usage in newer CPA deployments.
- **CPA Panel LTS** supplies **Weekly Quota Estimate** and **Single Credential Refresh** behavior to the **CPA-Manager Fork**.
- **Single Credential Refresh** applies to one credential at a time.

## Example dialogue

> **Dev:** "Are we merging CPA-Manager into the LTS panel?"
> **Domain expert:** "No. We are forking CPA-Manager and porting the LTS weekly quota estimate plus single credential refresh into it."

## Flagged ambiguities

- "Fuse CPA-Manager" was used ambiguously; resolved: the target is a **CPA-Manager Fork**, and **CPA Panel LTS** is only a source for selected quota capabilities.
- "Built-in statistics" was used ambiguously; resolved: newer CPA deployments rely on **Usage Service** for persistent request usage.
73 changes: 73 additions & 0 deletions src/components/quota/QuotaCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { act } from 'react';
import { create } from 'react-test-renderer';
import { describe, expect, it, vi } from 'vitest';
import { Button } from '@/components/ui/Button';
import { QuotaCard } from './QuotaCard';

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));

describe('QuotaCard', () => {
it('renders an icon refresh button for a single credential when refresh is available', () => {
const onRefresh = vi.fn();
let renderer: ReturnType<typeof create>;

act(() => {
renderer = create(
<QuotaCard
item={{ name: 'codex.json', type: 'codex' }}
quota={{ status: 'success' }}
resolvedTheme="light"
i18nPrefix="codex_quota"
cardClassName="codex-card"
defaultType="codex"
canRefresh
onRefresh={onRefresh}
renderQuotaItems={() => <span>quota</span>}
/>
);
});

const refreshButton = renderer!.root
.findAllByType(Button)
.find((button) => button.props['aria-label'] === 'codex_quota.refresh_button');

expect(refreshButton).toBeTruthy();

act(() => {
refreshButton!.props.onClick();
});

expect(onRefresh).toHaveBeenCalledTimes(1);
});

it('disables the single credential refresh button while that credential is loading', () => {
let renderer: ReturnType<typeof create>;

act(() => {
renderer = create(
<QuotaCard
item={{ name: 'codex.json', type: 'codex' }}
quota={{ status: 'loading' }}
resolvedTheme="light"
i18nPrefix="codex_quota"
cardClassName="codex-card"
defaultType="codex"
canRefresh
onRefresh={() => {}}
renderQuotaItems={() => <span>quota</span>}
/>
);
});

const refreshButton = renderer!.root
.findAllByType(Button)
.find((button) => button.props['aria-label'] === 'codex_quota.refresh_button');

expect(refreshButton?.props.disabled).toBe(true);
expect(refreshButton?.props.loading).toBe(true);
});
});
34 changes: 27 additions & 7 deletions src/components/quota/QuotaCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import { useTranslation } from 'react-i18next';
import type { ReactElement, ReactNode } from 'react';
import type { TFunction } from 'i18next';
import { Button } from '@/components/ui/Button';
import { IconRefreshCw } from '@/components/ui/icons';
import type { AuthFileItem, ResolvedTheme, ThemeColors } from '@/types';
import { TYPE_COLORS } from '@/utils/quota';
import styles from '@/pages/QuotaPage.module.scss';
Expand All @@ -26,10 +28,9 @@ export interface QuotaProgressBarProps {
export function QuotaProgressBar({
percent,
highThreshold,
mediumThreshold
mediumThreshold,
}: QuotaProgressBarProps) {
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const normalized = percent === null ? null : clamp(percent, 0, 100);
const fillClass =
normalized === null
Expand Down Expand Up @@ -79,7 +80,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
defaultType,
canRefresh = false,
onRefresh,
renderQuotaItems
renderQuotaItems,
}: QuotaCardProps<TState>) {
const { t } = useTranslation();

Expand All @@ -94,7 +95,11 @@ export function QuotaCard<TState extends QuotaStatusState>({
quota?.errorStatus,
quota?.error || t('common.unknown_error')
);
const idleMessageKey = onRefresh ? `${i18nPrefix}.idle` : (cardIdleMessageKey ?? `${i18nPrefix}.idle`);
const idleMessageKey = onRefresh
? `${i18nPrefix}.idle`
: (cardIdleMessageKey ?? `${i18nPrefix}.idle`);
const refreshLabel = t(`${i18nPrefix}.refresh_button`);
const refreshButtonLoading = quotaStatus === 'loading';

const getTypeLabel = (type: string): string => {
const key = `auth_files.filter_${type}`;
Expand All @@ -112,12 +117,27 @@ export function QuotaCard<TState extends QuotaStatusState>({
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {})
...(typeColor.border ? { border: typeColor.border } : {}),
}}
>
{getTypeLabel(displayType)}
</span>
<span className={styles.fileName}>{item.name}</span>
{onRefresh && (
<Button
type="button"
variant="ghost"
size="sm"
className={styles.cardRefreshButton}
onClick={onRefresh}
disabled={!canRefresh || refreshButtonLoading}
loading={refreshButtonLoading}
title={refreshLabel}
aria-label={refreshLabel}
>
{!refreshButtonLoading && <IconRefreshCw size={15} />}
</Button>
)}
</div>

<div className={styles.quotaSection}>
Expand All @@ -139,7 +159,7 @@ export function QuotaCard<TState extends QuotaStatusState>({
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t(`${i18nPrefix}.load_failed`, {
message: quotaErrorMessage
message: quotaErrorMessage,
})}
</div>
) : quota ? (
Expand Down
40 changes: 20 additions & 20 deletions src/components/quota/QuotaSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginatio
goToNext,
loading,
loadingScope,
setLoading
setLoading,
};
};

Expand All @@ -117,7 +117,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
loading,
disabled,
searchQuery = '',
sortMode = 'default'
sortMode = 'default',
}: QuotaSectionProps<TState, TData>) {
const { t } = useTranslation();
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
Expand All @@ -131,10 +131,10 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
const [viewMode, setViewMode] = useState<ViewMode>('paged');
const [showTooManyWarning, setShowTooManyWarning] = useState(false);

const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
files,
config
]);
const filteredFiles = useMemo(
() => files.filter((file) => config.filterFn(file)),
[files, config]
);
const normalizedSearchQuery = searchQuery.trim().toLowerCase();

const { quota, loadQuota } = useQuotaLoader(config);
Expand All @@ -154,7 +154,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
fileQuota?.status,
fileQuota?.error,
fileQuota?.errorStatus,
...(config.getSearchText?.(file, fileQuota, t) ?? [])
...(config.getSearchText?.(file, fileQuota, t) ?? []),
];

return stringifySearchValue(searchValues).some((value) =>
Expand All @@ -180,8 +180,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
if (leftKnown || rightKnown) {
if (!leftKnown) return 1;
if (!rightKnown) return -1;
const rankDiff =
sortMode === 'plan-desc' ? rightRank - leftRank : leftRank - rightRank;
const rankDiff = sortMode === 'plan-desc' ? rightRank - leftRank : leftRank - rightRank;
if (rankDiff !== 0) return rankDiff;
}

Expand All @@ -204,7 +203,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
goToPrev,
goToNext,
loading: sectionLoading,
setLoading
setLoading,
} = useQuotaPagination(displayFiles);

useEffect(() => {
Expand Down Expand Up @@ -235,6 +234,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({

const pendingQuotaRefreshRef = useRef(false);
const prevFilesLoadingRef = useRef(loading);
const singleRefreshInFlightRef = useRef<Set<string>>(new Set());

const handleRefresh = useCallback(() => {
pendingQuotaRefreshRef.current = true;
Expand Down Expand Up @@ -278,30 +278,35 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
async (file: AuthFileItem) => {
if (disabled || file.disabled) return;
if (quota[file.name]?.status === 'loading') return;
if (singleRefreshInFlightRef.current.has(file.name)) return;

singleRefreshInFlightRef.current.add(file.name);

setQuota((prev) => ({
...prev,
[file.name]: config.buildLoadingState()
[file.name]: config.buildLoadingState(),
}));

try {
const data = await config.fetchQuota(file, t);
setQuota((prev) => ({
...prev,
[file.name]: config.buildSuccessState(data)
[file.name]: config.buildSuccessState(data),
}));
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
setQuota((prev) => ({
...prev,
[file.name]: config.buildErrorState(message, status)
[file.name]: config.buildErrorState(message, status),
}));
showNotification(
t('auth_files.quota_refresh_failed', { name: file.name, message }),
'error'
);
} finally {
singleRefreshInFlightRef.current.delete(file.name);
}
},
[config, disabled, quota, setQuota, showNotification, t]
Expand Down Expand Up @@ -400,19 +405,14 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
</div>
{displayFiles.length > pageSize && effectiveViewMode === 'paged' && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={goToPrev}
disabled={currentPage <= 1}
>
<Button variant="secondary" size="sm" onClick={goToPrev} disabled={currentPage <= 1}>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: displayFiles.length
count: displayFiles.length,
})}
</div>
<Button
Expand Down
Loading
Loading