feat: live multi-provider quota panel (#1278)
* feat: live provider quota panel (Claude + Codex) Adds Claude and Codex plan usage to the context window percentage circle tooltip, querying their respective APIs directly using existing CLI auth tokens. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address review feedback - restore quota trigger, guard NaN date - Re-add triggerFetch() on agent idle/error in agent status subscription - Guard formatResetsAtLabel against NaN from malformed API date strings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve composer conflict and stub subscribe in websocket tests * feat(quota): add cursor, copilot, zai, grok, and kimi quota UI and improve type safety * feat(quota): make Claude and Codex pluggable, and map additional quota/credits metrics * security(quota): fix shell injection vulnerability by migrating exec to execFile * revert: restore original scripts/dev.ps1 and packages/desktop/scripts/dev.ps1 * Fix i18n resource test expectation * Fix quota tooltip empty-provider state * Preserve zero values in Grok quota * Fix empty quota fetch state * Ignore quota frames in relay reconnect tests * Persist refreshed Codex tokens to source auth file * fix(quota): read Claude OAuth credentials from the macOS login Keychain On macOS, Claude Code stores its OAuth credential in the login Keychain (generic-password item, service "Claude Code-credentials"), not in ~/.claude/.credentials.json — that file usually does not exist there, so the Claude provider's existsSync check failed and macOS users silently got no Claude quota. Read it via the macOS `security` CLI (its ACL only trusts /usr/bin/security to decrypt; a native Keychain read would prompt), and stay read-only there since Claude Code owns the refresh/persist. Linux and Windows keep using ~/.claude/.credentials.json unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix quota keychain timeout and local host reconciliation * Fix terminal notification test quota stub * Ignore quota frames in terminal notification tests * fix(quota): bypass Claude token refresh on macOS Keychain-backed logins * fix(dev): update local dev daemon port to 6768 on Windows scripts/dev.ps1 * fix(dev): bypass Unix dev-daemon.sh on Windows in dev.ps1 * fix(client): handle and dispatch provider_quota event in DaemonClient * fix(desktop): fix PowerShell quote stripping in desktop dev.ps1 config seeding * fix(desktop): execute config update via temp file to avoid PowerShell quoting bugs * fix(desktop): fix concurrently command invocation on Windows in dev.ps1 * fix(desktop): resolve path escaping and environment setting syntax errors in dev.ps1 for Windows * Add on-demand provider usage views * Fix Cursor usage billing dates * Move provider usage renderer coverage to e2e * Use provider manifest for quota fetchers * Add provider usage fetch timeouts * Reshape provider usage fetchers --------- Co-authored-by: ABorakati <ABorakati@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com> Co-authored-by: lumingjun <lumingjun@bytedance.com>
This commit is contained in:
co-authored by
ABorakati
Claude Sonnet 4.6
Mohamed Boudra
lumingjun
parent
2b0740ff84
commit
acea7f3d24
@@ -0,0 +1,201 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ProviderUsageBalanceBar } from "./balance-bar";
|
||||
import { formatAgo } from "./format";
|
||||
import type { ProviderUsage } from "./types";
|
||||
import { ProviderUsageWindowBar } from "./window-bar";
|
||||
|
||||
interface ProviderUsageIconProps {
|
||||
iconKey: string;
|
||||
size: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function ProviderUsageIcon({ iconKey, size, color = "" }: ProviderUsageIconProps) {
|
||||
const Icon = getProviderIcon(iconKey);
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon);
|
||||
|
||||
const foregroundIconColor = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
function statusText(usage: ProviderUsage): string | null {
|
||||
if (usage.status === "available") return null;
|
||||
return usage.status === "error" ? "Error" : "Unavailable";
|
||||
}
|
||||
|
||||
function footerText(usage: ProviderUsage): string | null {
|
||||
const updated = formatAgo(usage.fetchedAt);
|
||||
const parts = [usage.sourceLabel, updated ? `Updated ${updated}` : null].filter(
|
||||
(part): part is string => typeof part === "string" && part.length > 0,
|
||||
);
|
||||
return parts.length > 0 ? parts.join(" · ") : null;
|
||||
}
|
||||
|
||||
export function ProviderUsageCard({
|
||||
usage,
|
||||
compact = false,
|
||||
}: {
|
||||
usage: ProviderUsage;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const status = statusText(usage);
|
||||
const footer = footerText(usage);
|
||||
const balances = usage.balances ?? [];
|
||||
const details = usage.details ?? [];
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
||||
[compact],
|
||||
);
|
||||
const dotStyle = useMemo(
|
||||
() => [
|
||||
styles.statusDot,
|
||||
usage.status === "available" && styles.statusDotAvailable,
|
||||
usage.status === "error" && styles.statusDotError,
|
||||
],
|
||||
[usage.status],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={styles.header}>
|
||||
<ThemedProviderUsageIcon
|
||||
iconKey={usage.providerId}
|
||||
size={16}
|
||||
uniProps={foregroundIconColor}
|
||||
/>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{usage.displayName}
|
||||
</Text>
|
||||
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
|
||||
<View style={styles.headerSpacer} />
|
||||
{status ? (
|
||||
<View style={styles.statusRow}>
|
||||
<View style={dotStyle} />
|
||||
<Text style={styles.statusLabel}>{status}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{usage.error ? (
|
||||
<Text style={styles.error} numberOfLines={3}>
|
||||
{usage.error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{usage.windows.length > 0 || balances.length > 0 ? (
|
||||
<View style={styles.bars}>
|
||||
{usage.windows.map((window) => (
|
||||
<ProviderUsageWindowBar key={window.id} window={window} />
|
||||
))}
|
||||
{balances.map((balance) => (
|
||||
<ProviderUsageBalanceBar key={balance.id} balance={balance} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{details.length > 0 ? (
|
||||
<View style={styles.details}>
|
||||
{details.map((detail) => (
|
||||
<View key={detail.id} style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel} numberOfLines={1}>
|
||||
{detail.label}
|
||||
</Text>
|
||||
<Text style={styles.detailValue} numberOfLines={1}>
|
||||
{detail.value}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{footer ? (
|
||||
<Text style={styles.footer} numberOfLines={1}>
|
||||
{footer}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
containerPadded: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
containerCompact: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
name: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
headerSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
statusDotAvailable: {
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
},
|
||||
statusDotError: {
|
||||
backgroundColor: theme.colors.statusDanger,
|
||||
},
|
||||
statusLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
bars: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
details: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
detailRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
detailLabel: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
detailValue: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
error: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.fontSize.xs * 1.4,
|
||||
},
|
||||
footer: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user