From 911047d009348a2eff5ed2a68855f4b9c867bf6d Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 7 Sep 2026 16:09:57 +0800 Subject: [PATCH] fix: replace native dialogs and improve quota reset handling - Use consistent in-app dialogs for confirmation actions - Keep quota refreshes free of prompts and automatic paid probes - Add a separate confirmation flow for xAI availability tests - Require explicit approval for Codex resets and prevent duplicate requests - Show reset outcomes and require a quota refresh before retrying failures - Cover confirmation and reset flows with fully mocked tests --- src-tauri/capabilities/default.json | 3 +- src/components/ConfirmationDialog.tsx | 111 ++++++++++++++++ src/components/QuotaActionFeedback.tsx | 20 +++ src/i18n/ja.ts | 23 +++- src/i18n/locales/en.ts | 23 +++- src/i18n/locales/zh-CN.ts | 23 +++- src/pages/ApiAccessPage.tsx | 5 +- src/pages/AuthFileManagementPage.tsx | 30 ++++- src/pages/QuotaPage.tsx | 69 ++++++---- src/pages/ThinkingAliasesPage.tsx | 7 +- src/pages/UsageRecordsPage.tsx | 9 +- src/services/quotaActions.ts | 76 +++++++---- src/services/quotaService.ts | 64 ++++++---- src/styles.css | 73 +++++++++++ tests/confirmationDialog.test.tsx | 25 ++++ tests/quotaConfirmation.test.ts | 168 +++++++++++++------------ tests/quotaRendering.test.tsx | 4 +- tests/quotaRequests.test.ts | 24 ++-- 18 files changed, 571 insertions(+), 186 deletions(-) create mode 100644 src/components/ConfirmationDialog.tsx create mode 100644 src/components/QuotaActionFeedback.tsx create mode 100644 tests/confirmationDialog.test.tsx diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8104afe..e06f87d 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,7 +9,6 @@ "core:window:allow-set-theme", "core:window:allow-set-background-color", "core:webview:allow-set-webview-background-color", - "dialog:allow-open", - "dialog:allow-message" + "dialog:allow-open" ] } diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx new file mode 100644 index 0000000..2a8c545 --- /dev/null +++ b/src/components/ConfirmationDialog.tsx @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useId, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { AlertTriangle, X } from 'lucide-react'; +import { useI18n } from '../i18n'; + +export type ConfirmationOptions = { + title: string; + message: string; + confirmText?: string; + warning?: string; + details?: { label: string; value: string }[]; + variant?: 'primary' | 'danger'; +}; + +type ConfirmationDialogProps = ConfirmationOptions & { onDecision: (confirmed: boolean) => void }; + +export function ConfirmationDialog({ title, message, confirmText, warning, details, variant = 'primary', onDecision }: ConfirmationDialogProps) { + const { t } = useI18n(); + const titleId = useId(); + const descriptionId = useId(); + const dialogRef = useRef(null); + const cancelRef = useRef(null); + useEffect(() => { + const previousFocus = document.activeElement; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + cancelRef.current?.focus(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + onDecision(false); + } else if (event.key === 'Tab') { + const buttons = Array.from(dialogRef.current?.querySelectorAll('button:not(:disabled)') ?? []); + const first = buttons[0]; + const last = buttons[buttons.length - 1]; + if (event.shiftKey && (document.activeElement === first || !dialogRef.current?.contains(document.activeElement))) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && (document.activeElement === last || !dialogRef.current?.contains(document.activeElement))) { + event.preventDefault(); + first?.focus(); + } + } + }; + document.addEventListener('keydown', onKeyDown, true); + return () => { + document.removeEventListener('keydown', onKeyDown, true); + document.body.style.overflow = previousOverflow; + if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus(); + }; + }, [onDecision]); + + const content = ( +
{ + if (event.currentTarget !== event.target) return; + event.preventDefault(); + onDecision(false); + }}> +
+
+
+ +
+

{message}

+ {details?.length ?
{details.map((detail) =>
{detail.label}
{detail.value}
)}
: null} + {warning ?

{warning}

: null} +
+ + +
+
+
+ ); + return typeof document === 'undefined' ? content : createPortal(content, document.body); +} + +type PendingConfirmation = { options: ConfirmationOptions; resolve: (confirmed: boolean) => void }; + +export function useConfirmation() { + const pendingRef = useRef(null); + const mountedRef = useRef(true); + const [pending, setPending] = useState(null); + const askConfirmation = useCallback((options: ConfirmationOptions): Promise => { + if (!mountedRef.current) return Promise.resolve(false); + pendingRef.current?.resolve(false); + return new Promise((resolve) => { + const request = { options, resolve }; + pendingRef.current = request; + setPending(request); + }); + }, []); + const decide = useCallback((confirmed: boolean) => { + if (!pending || pendingRef.current !== pending) return; + pendingRef.current = null; + setPending(null); + pending.resolve(confirmed); + }, [pending]); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + pendingRef.current?.resolve(false); + pendingRef.current = null; + }; + }, []); + return { + askConfirmation, + confirmationDialog: pending ? : null, + }; +} diff --git a/src/components/QuotaActionFeedback.tsx b/src/components/QuotaActionFeedback.tsx new file mode 100644 index 0000000..6f9e67e --- /dev/null +++ b/src/components/QuotaActionFeedback.tsx @@ -0,0 +1,20 @@ +import { AlertCircle, CheckCircle2 } from 'lucide-react'; +import { useI18n } from '../i18n'; +import type { QuotaState } from '../services/quotaService'; + +export function QuotaActionFeedback({ quota }: { quota: QuotaState }) { + const { t } = useI18n(); + const result = quota.actionResult; + if (!result) return null; + const successful = result.status === 'success'; + const message = result.action === 'probe' + ? t(successful ? 'quota.probe.success' : 'quota.probe.failed', { error: result.error ?? '' }) + : t(successful ? 'quota.resetResult.submitted' : result.status === 'refresh-error' + ? 'quota.resetResult.refreshFailed' : 'quota.resetResult.failed', { error: result.error ?? '' }); + return ( +
+ {successful ?
+ ); +} diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index e1118d7..32502bd 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -185,6 +185,25 @@ export const jaOverrides = { 'quota.empty.title': '照会可能なクォータがありません', 'quota.empty.description': 'まず認証ファイルにクォータ照会対応の認証情報を追加してください。', 'quota.unknownProvider': '不明', + 'common.confirm': '確認', + 'quota.probe.button': '利用可否をテスト', + 'quota.probe.title': 'xAI 有料 API のテスト', + 'quota.probe.confirm': '「{name}」の有料 API の利用可否をテストしますか?', + 'quota.probe.confirmButton': 'テストを実行', + 'quota.probe.running': '利用可否をテスト中…', + 'quota.probe.success': '利用可否のテストに成功しました。残りのクォータは取得されません。', + 'quota.probe.failed': '利用可否のテストに失敗しました:{error}', + 'quota.service.xaiPaidAccount': '有料 API アカウント', + 'quota.service.xaiPaidQuotaUnavailable': 'このアカウントの残りクォータは提供されていません。接続の確認は「利用可否をテスト」から実行してください。', + 'quota.service.error.xaiProbeOnly': 'この利用可否テストは xAI 認証情報のみをサポートします', + 'quota.confirm.button': 'リセットを実行', + 'quota.confirm.applicableLabel': '現在適用可能な回数', + 'quota.resetting': 'リセットを送信してクォータを更新中…', + 'quota.resetNotApplicable': '現在適用可能なリセット回数がありません。クォータを更新して確認してください', + 'quota.resetCheckFirst': '前回の結果が未確認です。重複リセットを避けるため、先にクォータを更新してください', + 'quota.resetResult.submitted': 'リセット要求を送信し、クォータを再取得しました。カードの最新クォータを確認してください。', + 'quota.resetResult.refreshFailed': 'リセット要求は送信済みですが、クォータの更新に失敗しました:{error}。再消費を避けるため、先にクォータを更新してください。', + 'quota.resetResult.failed': 'リセットの成功を確認できませんでした:{error}。再試行する前にクォータを更新してください。', 'quota.reset': 'クォータをリセット', 'quota.fileDisabled': '認証ファイルは無効です', 'quota.refresh': 'クォータを取得/更新', @@ -199,7 +218,7 @@ export const jaOverrides = { 'quota.subscriptionExpiry': 'サブスクリプション期限:{time}', 'quota.resetApplicable': '現在適用可能:{count} 回', 'quota.resetCreditsWarning': 'リセット回数の詳細取得に失敗:{error}', - 'quota.xaiProbeConfirm': 'xAI 有料アカウントはこの請求クォータ API に対応していません。短い grok-4.5 リクエストで利用可能か確認しますか?少額の料金が発生する可能性があり、残りクォータは取得できません。', + 'quota.xaiProbeConfirm': '最小限の grok-4.5 リクエストを 1 回送信し、少額の料金が発生する可能性があります。利用可否のみをテストし、残りのクォータは取得しません。', 'quota.service.window.sevenDayFable': '7 日間 Fable 枠', 'quota.service.xaiPaidHealth': '有料 API 利用可能', 'quota.service.xaiPaidHealthDetail': '短い会話テストに成功。残りクォータは提供されていません', @@ -209,7 +228,7 @@ export const jaOverrides = { 'quota.confirm.cost': 'この操作では手動リセットを 1 回消費します。', 'quota.confirm.available': '現在利用可能:{count} 回', 'quota.confirm.expiry': '最も早い有効期限:{time}', - 'quota.confirm.warning': '「確認」を押した場合のみ実行されます。「キャンセル」ではリセット回数を消費しません。', + 'quota.confirm.warning': '手動リセットを 1 回消費します。「リセットを実行」をクリックした場合のみ実行します。キャンセルや閉じる操作では消費しません。', 'oauth.title': 'OAuth ログイン', 'oauth.tabs.label': 'OAuth 管理ページ', 'oauth.loginSuccess': '{provider} へのログインに成功しました', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 908476c..2dc6268 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -172,6 +172,25 @@ export const en: Record = { 'quota.empty.title': 'No quota data available', 'quota.empty.description': 'Add a credential that supports quota queries under Auth Files first.', 'quota.unknownProvider': 'Unknown', + 'common.confirm': 'Confirm', + 'quota.probe.button': 'Test Availability', + 'quota.probe.title': 'Test xAI Paid API', + 'quota.probe.confirm': 'Test paid API availability for "{name}"?', + 'quota.probe.confirmButton': 'Confirm Test', + 'quota.probe.running': 'Testing availability…', + 'quota.probe.success': 'Availability test passed; this test does not report remaining quota.', + 'quota.probe.failed': 'Availability test failed: {error}', + 'quota.service.xaiPaidAccount': 'Paid API account', + 'quota.service.xaiPaidQuotaUnavailable': 'Remaining quota is not provided for this account. Use Test Availability separately to check the connection.', + 'quota.service.error.xaiProbeOnly': 'This availability test only supports xAI credentials', + 'quota.confirm.button': 'Confirm Reset', + 'quota.confirm.applicableLabel': 'Currently applicable resets', + 'quota.resetting': 'Submitting reset and refreshing quota…', + 'quota.resetNotApplicable': 'No reset credits currently apply. Refresh quota before trying again', + 'quota.resetCheckFirst': 'The previous result is unconfirmed. Refresh quota first to avoid another reset', + 'quota.resetResult.submitted': 'The reset request was submitted and quota was refreshed. Check the latest quota on this card.', + 'quota.resetResult.refreshFailed': 'The reset request was submitted, but refreshing quota failed: {error}. Refresh quota first to avoid consuming another reset credit.', + 'quota.resetResult.failed': 'Could not confirm the reset: {error}. Refresh quota before deciding whether to retry.', 'quota.reset': 'Reset Quota', 'quota.fileDisabled': 'Authentication file is disabled', 'quota.refresh': 'Fetch or refresh quota', @@ -186,7 +205,7 @@ export const en: Record = { 'quota.subscriptionExpiry': 'Subscription expires: {time}', 'quota.resetApplicable': 'Currently applicable: {count}', 'quota.resetCreditsWarning': 'Reset credit details unavailable: {error}', - 'quota.xaiProbeConfirm': 'Paid xAI accounts do not expose this billing quota API. Send a tiny grok-4.5 request to check availability? This may incur a small charge and does not report remaining quota.', + 'quota.xaiProbeConfirm': 'This sends one minimal grok-4.5 request and may incur a small charge. It only tests availability and does not report remaining quota.', 'quota.service.window.sevenDayFable': '7-day Fable window', 'quota.service.xaiPaidHealth': 'Paid API available', 'quota.service.xaiPaidHealthDetail': 'Tiny chat probe succeeded; remaining quota is not provided', @@ -196,7 +215,7 @@ export const en: Record = { 'quota.confirm.cost': 'This will consume one manual reset credit.', 'quota.confirm.available': 'Currently available: {count}', 'quota.confirm.expiry': 'Earliest expiry: {time}', - 'quota.confirm.warning': 'The reset runs only after you confirm. Canceling will not consume a reset credit.', + 'quota.confirm.warning': 'This consumes one manual reset credit. It runs only after Confirm Reset; canceling or closing does not consume a credit.', 'oauth.title': 'OAuth Sign-In', 'oauth.tabs.label': 'OAuth management sections', 'oauth.loginSuccess': '{provider} sign-in succeeded', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index b82f21c..0019067 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -171,6 +171,25 @@ export const zhCN = { 'quota.empty.title': '暂无可查询配额', 'quota.empty.description': '先在凭证文件中添加支持配额查询的凭据。', 'quota.unknownProvider': '未知', + 'common.confirm': '确认', + 'quota.probe.button': '测试可用性', + 'quota.probe.title': '测试 xAI 付费 API', + 'quota.probe.confirm': '确认测试“{name}”的付费 API 可用性吗?', + 'quota.probe.confirmButton': '确认测试', + 'quota.probe.running': '正在测试可用性…', + 'quota.probe.success': '可用性测试通过;此操作不提供剩余额度。', + 'quota.probe.failed': '可用性测试失败:{error}', + 'quota.service.xaiPaidAccount': '付费 API 账号', + 'quota.service.xaiPaidQuotaUnavailable': '此账号不提供剩余额度。如需检测连接,请单独点击“测试可用性”。', + 'quota.service.error.xaiProbeOnly': '只有 xAI 凭证支持此可用性测试', + 'quota.confirm.button': '确认重置', + 'quota.confirm.applicableLabel': '当前适用次数', + 'quota.resetting': '正在提交重置并刷新额度…', + 'quota.resetNotApplicable': '当前没有适用的重置次数,请刷新额度后再确认', + 'quota.resetCheckFirst': '上次操作结果尚未确认,请先刷新额度,避免重复重置', + 'quota.resetResult.submitted': '重置请求已提交,额度已重新获取。请查看卡片中的最新额度。', + 'quota.resetResult.refreshFailed': '重置请求已提交,但额度刷新失败:{error}。请先刷新查询,避免重复消耗重置机会。', + 'quota.resetResult.failed': '无法确认重置成功:{error}。请先刷新额度,再决定是否重试。', 'quota.reset': '重置额度', 'quota.fileDisabled': '凭证文件已停用', 'quota.refresh': '获取/刷新额度', @@ -185,7 +204,7 @@ export const zhCN = { 'quota.subscriptionExpiry': '订阅到期:{time}', 'quota.resetApplicable': '当前适用:{count} 次', 'quota.resetCreditsWarning': '重置次数详情查询失败:{error}', - 'quota.xaiProbeConfirm': 'xAI 付费账户不提供此账单额度接口。是否发送极短的 grok-4.5 请求检测可用性?这可能产生少量费用,且不会返回剩余额度。', + 'quota.xaiProbeConfirm': '将发送一次极短的 grok-4.5 请求,可能产生少量费用。此操作仅测试可用性,不会返回剩余额度。', 'quota.service.window.sevenDayFable': '7 天 Fable 窗口', 'quota.service.xaiPaidHealth': '付费 API 可用', 'quota.service.xaiPaidHealthDetail': '极短对话探测成功;上游未提供剩余额度', @@ -195,7 +214,7 @@ export const zhCN = { 'quota.confirm.cost': '本操作将消耗 1 次主动重置机会。', 'quota.confirm.available': '当前可用:{count} 次', 'quota.confirm.expiry': '最早过期:{time}', - 'quota.confirm.warning': '只有点击“确定”后才会执行;点击“取消”不会消耗重置机会。', + 'quota.confirm.warning': '将消耗 1 次主动重置机会。只有点击“确认重置”才会执行,取消或关闭不会消耗次数。', 'oauth.title': 'OAuth 登录', 'oauth.tabs.label': 'OAuth 管理子页面', 'oauth.loginSuccess': '{provider} 登录成功', diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 71b07f5..69a09bc 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -1,3 +1,4 @@ +import { useConfirmation } from '../components/ConfirmationDialog'; import { type CSSProperties, FormEvent, @@ -794,6 +795,7 @@ export const providerRecordWithDisabledState = ( }; export function ApiAccessPage() { + const { askConfirmation, confirmationDialog } = useConfirmation(); const { t } = useI18n(); const [records, setRecords] = useState(emptyRecords); const [activeCategory, setActiveCategory] = useState('codex-api-key'); @@ -1054,7 +1056,7 @@ export function ApiAccessPage() { }; const deleteRow = async (row: ProviderRow) => { - if (!window.confirm(t('apiAccess.deleteConfirm', { remark: row.remark || row.name }))) return; + if (!await askConfirmation({ title: t('common.delete'), message: t('apiAccess.deleteConfirm', { remark: row.remark || row.name }), confirmText: t('common.delete'), variant: 'danger' })) return; setFeedbackRow(providerDragId(row)); feedback.clearNotice(); setBusy(true); @@ -1176,6 +1178,7 @@ export function ApiAccessPage() { return (
+ {confirmationDialog}

{t('apiAccess.title')}

diff --git a/src/pages/AuthFileManagementPage.tsx b/src/pages/AuthFileManagementPage.tsx index caee7e8..f4858f4 100644 --- a/src/pages/AuthFileManagementPage.tsx +++ b/src/pages/AuthFileManagementPage.tsx @@ -1,5 +1,7 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { confirm } from '@tauri-apps/plugin-dialog'; +import { useConfirmation } from '../components/ConfirmationDialog'; +import { QuotaActionFeedback } from '../components/QuotaActionFeedback'; +import { probeXaiWithConfirmation } from '../services/quotaActions'; import { InlineNotice, useAppNotice } from '../appNotice'; import { AuthFileModelsDialog } from '../components/AuthFileModelsDialog'; import { @@ -119,7 +121,7 @@ function AuthFileQuotaSummary({ quota }: { quota: QuotaState }) { return (
- {t('authFiles.quota.loading')} + {t(quota.pendingAction === 'probe' ? 'quota.probe.running' : 'authFiles.quota.loading')}
); } @@ -163,6 +165,7 @@ function AuthFileQuotaSummary({ quota }: { quota: QuotaState }) { export function AuthFileManagementPage() { const { t } = useI18n(); + const { askConfirmation, confirmationDialog } = useConfirmation(); const [files, setFiles] = useState([]); const [filter, setFilter] = useState(''); const [providerFilter, setProviderFilter] = useState('all'); @@ -226,14 +229,26 @@ export function AuthFileManagementPage() { if (getQuotaCacheSnapshot()[key]?.status === 'loading') return; const cacheGeneration = captureQuotaCacheGeneration(); updateQuotaCache((current) => ({ ...current, [key]: { status: 'loading', rows: [] } })); - const result = await loadQuota(file, { - confirmXaiPaidProbe: () => confirm(t('quota.xaiProbeConfirm'), { title: t('quota.title'), kind: 'warning' }), - }); + const result = await loadQuota(file); commitQuotaCacheIfCurrent(cacheGeneration, () => { updateQuotaCache((current) => ({ ...current, [key]: result })); }); }; + const probeXai = async (file: AuthFile) => { + setError(''); + try { + await probeXaiWithConfirmation(file, () => askConfirmation({ + title: t('quota.probe.title'), + message: t('quota.probe.confirm', { name: fileName(file) }), + warning: t('quota.xaiProbeConfirm'), + confirmText: t('quota.probe.confirmButton'), + })); + } catch (requestError) { + setError(String(requestError)); + } + }; + const closeOauthModels = () => { if (oauthModelSaveRef.current) return; oauthModelRequestRef.current += 1; @@ -432,7 +447,7 @@ export function AuthFileManagementPage() { setError(t('authFiles.runtimeDeleteError')); return; } - if (!window.confirm(t('authFiles.deleteConfirm', { name }))) return; + if (!await askConfirmation({ title: t('common.delete'), message: t('authFiles.deleteConfirm', { name }), confirmText: t('common.delete'), variant: 'danger' })) return; setBusy(true); setError(''); try { @@ -473,6 +488,7 @@ export function AuthFileManagementPage() { return (
+ {confirmationDialog}

{t('authFiles.title')}

@@ -542,6 +558,7 @@ export function AuthFileManagementPage() {
{quotaProviderForFile(file) ? : null} + {quotaProviderForFile(file) === 'xai' ? : null} {providerKey(file) ? : null} {providerKey(file) ? : null} @@ -550,6 +567,7 @@ export function AuthFileManagementPage() {
{quotaProviderForFile(file) && quotas[quotaKey(file)]?.status !== 'idle' ? : null} + ); })} diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index 425b789..8bb5d6b 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { AlertCircle, LoaderCircle, RefreshCw } from 'lucide-react'; -import { confirm } from '@tauri-apps/plugin-dialog'; -import { resetCodexQuotaWithConfirmation } from '../services/quotaActions'; +import { useConfirmation } from '../components/ConfirmationDialog'; +import { QuotaActionFeedback } from '../components/QuotaActionFeedback'; +import { canResetCodexQuota, probeXaiWithConfirmation, resetCodexQuotaWithConfirmation } from '../services/quotaActions'; import antigravityIcon from '../assets/icons/antigravity.svg'; import claudeIcon from '../assets/icons/claude.svg'; import codexIcon from '../assets/icons/codex.svg'; @@ -44,6 +45,7 @@ const REFRESH_CONCURRENCY = 4; export function QuotaPage() { const { locale, t } = useI18n(); + const { askConfirmation, confirmationDialog } = useConfirmation(); const [files, setFiles] = useState([]); const quotas = useQuotaCache(); const [loading, setLoading] = useState(true); @@ -85,9 +87,7 @@ export function QuotaPage() { if (getQuotaCacheSnapshot()[key]?.status === 'loading') return; const cacheGeneration = captureQuotaCacheGeneration(); updateQuotaCache((current) => ({ ...current, [key]: { status: 'loading', rows: [] } })); - const result = await loadQuota(file, { - confirmXaiPaidProbe: () => confirm(t('quota.xaiProbeConfirm'), { title: t('quota.title'), kind: 'warning' }), - }); + const result = await loadQuota(file); commitQuotaCacheIfCurrent(cacheGeneration, () => { updateQuotaCache((current) => ({ ...current, [key]: result })); }); @@ -96,19 +96,35 @@ export function QuotaPage() { const resetCodexQuota = useCallback(async (file: AuthFile, quota: QuotaState) => { setError(''); try { - await resetCodexQuotaWithConfirmation(file, () => confirm([ - t('quota.confirm.title', { name: fileName(file) }), - '', - t('quota.confirm.cost'), - t('quota.confirm.available', { count: quota.resetCredits ?? '—' }), - t('quota.confirm.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) }), - '', - t('quota.confirm.warning'), - ].join('\n'), { title: t('quota.reset'), kind: 'warning' })); + await resetCodexQuotaWithConfirmation(file, () => askConfirmation({ + title: t('quota.reset'), + message: t('quota.confirm.title', { name: fileName(file) }), + confirmText: t('quota.confirm.button'), + details: [ + { label: t('quota.resetCredits'), value: String(quota.resetCredits ?? '—') }, + { label: t('quota.confirm.applicableLabel'), value: String(quota.resetCreditsApplicable ?? '—') }, + { label: t('quota.earliestExpiry'), value: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) }, + ], + warning: t('quota.confirm.warning'), + })); } catch (requestError) { setError(requestError instanceof Error ? requestError.message : String(requestError)); } - }, [locale, t]); + }, [askConfirmation, locale, t]); + + const probeXai = useCallback(async (file: AuthFile) => { + setError(''); + try { + await probeXaiWithConfirmation(file, () => askConfirmation({ + title: t('quota.probe.title'), + message: t('quota.probe.confirm', { name: fileName(file) }), + warning: t('quota.xaiProbeConfirm'), + confirmText: t('quota.probe.confirmButton'), + })); + } catch (requestError) { + setError(String(requestError)); + } + }, [askConfirmation, t]); const refreshAll = useCallback(async () => { if (Object.values(getQuotaCacheSnapshot()).some((quota) => quota.status === 'loading')) return; @@ -125,11 +141,7 @@ export function QuotaPage() { for (let index = 0; index < files.length; index += REFRESH_CONCURRENCY) { const batch = files.slice(index, index + REFRESH_CONCURRENCY); await Promise.all(batch.map(async (file) => { - const result = await loadQuota(file, { - confirmXaiPaidProbe: () => confirm(`${fileName(file)}\n\n${t('quota.xaiProbeConfirm')}`, { - title: t('quota.title'), kind: 'warning', - }), - }); + const result = await loadQuota(file); commitQuotaCacheIfCurrent(cacheGeneration, () => { updateQuotaCache((current) => ({ ...current, [quotaKey(file)]: result })); }); @@ -157,6 +169,7 @@ export function QuotaPage() { return (
+ {confirmationDialog}

{t('quota.title')}

@@ -179,7 +192,7 @@ export function QuotaPage() { {grouped.map(([provider, items]) => (

{providerMeta[provider].label}

{t(items.length === 1 ? 'quota.credentials.one' : 'quota.credentials.other', { count: items.length })}
-
{items.map(({ file, quota }) => void refreshOne(file)} onReset={provider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} />)}
+
{items.map(({ file, quota }) => void refreshOne(file)} onReset={provider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} onProbe={provider === 'xai' ? () => void probeXai(file) : undefined} />)}
))}
@@ -188,7 +201,7 @@ export function QuotaPage() { ); } -export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile; quota: QuotaState; onRefresh: () => void; onReset?: () => void }) { +export function QuotaCard({ file, quota, onRefresh, onReset, onProbe }: { file: AuthFile; quota: QuotaState; onRefresh: () => void; onReset?: () => void; onProbe?: () => void }) { const { locale, t } = useI18n(); const now = useQuotaClock() + (quota.serverTimeOffsetMs ?? 0); const provider = providerForFile(file); @@ -196,9 +209,17 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile; const disabled = readBoolean(file, 'disabled'); return (
-
{name}{provider ? providerMeta[provider].label : t('quota.unknownProvider')}{quota.plan ? ` · ${quota.plan}` : ''}
{onReset && (quota.resetCredits ?? 0) > 0 ? : null}
+
+
{name}{provider ? providerMeta[provider].label : t('quota.unknownProvider')}{quota.plan ? ' · ' + quota.plan : ''}
+
+ {onReset && (quota.resetCredits ?? 0) > 0 ? : null} + {onProbe ? : null} + +
+
+ {quota.status === 'idle' ?
{disabled ? t('quota.fileDisabled') : t('quota.notFetched')}
: null} - {quota.status === 'loading' ?
{t('quota.querying')}
: null} + {quota.status === 'loading' ?
{t(quota.pendingAction === 'reset' ? 'quota.resetting' : quota.pendingAction === 'probe' ? 'quota.probe.running' : 'quota.querying')}
: null} {quota.status === 'error' ?
{quota.error}
: null} {quota.status === 'success' && provider === 'codex' ?
{t('quota.resetCredits')} {quota.resetCredits ?? '—'} diff --git a/src/pages/ThinkingAliasesPage.tsx b/src/pages/ThinkingAliasesPage.tsx index 35e6910..595ff49 100644 --- a/src/pages/ThinkingAliasesPage.tsx +++ b/src/pages/ThinkingAliasesPage.tsx @@ -1,3 +1,4 @@ +import { useConfirmation } from '../components/ConfirmationDialog'; import { type KeyboardEvent, useCallback, @@ -174,6 +175,7 @@ const thinkingAliasSourceDetail = (source: ThinkingAliasSource) => ( ); export function ThinkingAliasesPage() { + const { askConfirmation, confirmationDialog } = useConfirmation(); const { t } = useI18n(); const [thinkingEntries, setThinkingEntries] = useState([]); const [speedEntries, setSpeedEntries] = useState([]); @@ -419,9 +421,7 @@ export function ThinkingAliasesPage() { }; const deleteAlias = async (entry: AliasListEntry) => { - if (!window.confirm( - t('aliases.deleteConfirm', { alias: entry.alias }), - )) return; + if (!await askConfirmation({ title: t('common.delete'), message: t('aliases.deleteConfirm', { alias: entry.alias }), confirmText: t('common.delete'), variant: 'danger' })) return; setBusyAlias(entry.alias); setBusyAction('delete'); setError(''); @@ -450,6 +450,7 @@ export function ThinkingAliasesPage() { return (
+ {confirmationDialog}
{error ?
{error}
: null} {!error && notice ?
{notice}
: null} diff --git a/src/pages/UsageRecordsPage.tsx b/src/pages/UsageRecordsPage.tsx index a6e097f..536b71a 100644 --- a/src/pages/UsageRecordsPage.tsx +++ b/src/pages/UsageRecordsPage.tsx @@ -1,3 +1,4 @@ +import { useConfirmation } from '../components/ConfirmationDialog'; import { useCallback, useEffect, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; @@ -724,13 +725,14 @@ export function UsageRecordsPage() { } function UsageDataManagementView() { + const { askConfirmation, confirmationDialog } = useConfirmation(); const { t } = useI18n(); const [running, setRunning] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(''); const repair = async () => { - if (!window.confirm(t('usage.dataManagement.confirm'))) return; + if (!await askConfirmation({ title: t('usage.dataManagement.title'), message: t('usage.dataManagement.confirm') })) return; setRunning(true); setError(''); setResult(null); @@ -746,6 +748,7 @@ function UsageDataManagementView() { return (
+ {confirmationDialog}