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
222 changes: 222 additions & 0 deletions apps/app/src/components/settings/UsageLimitsSettingsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import type {
ProviderUsage,
ProviderUsageWindow,
} from "@bb/host-daemon-contract";
import { Button } from "@/components/ui/button";
import { SettingsSection } from "@/components/ui/settings-section";
import { useSystemUsageLimits } from "@/hooks/queries/system-queries";
import { cn } from "@/lib/utils";

interface ProviderConfig {
key: "codex" | "claudeCode";
name: string;
signInHint: string;
expiredHint: string;
}

const PROVIDERS: ProviderConfig[] = [
{
key: "codex",
name: "Codex",
signInHint: "Run `codex` to sign in and see your usage.",
expiredHint: "Your Codex session expired. Run `codex`, then refresh.",
},
{
key: "claudeCode",
name: "Claude Code",
signInHint: "Run `claude` to sign in and see your usage.",
expiredHint: "Your Claude session expired. Run `claude`, then refresh.",
},
];

function barColorClass(usedPercent: number): string {
if (usedPercent >= 95) {
return "bg-destructive";
}
if (usedPercent >= 80) {
return "bg-warning";
}
return "bg-primary";
}

function formatReset(resetsAt: string | null): string | null {
if (!resetsAt) {
return null;
}
const reset = new Date(resetsAt);
if (Number.isNaN(reset.getTime())) {
return null;
}
const diffMs = reset.getTime() - Date.now();
if (diffMs <= 0) {
return "Resetting now";
}

const diffMinutes = Math.round(diffMs / 60_000);
if (diffMinutes < 60) {
return `Resets in ${diffMinutes} min`;
}

const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) {
const minutes = diffMinutes % 60;
return minutes > 0
? `Resets in ${diffHours} hr ${minutes} min`
: `Resets in ${diffHours} hr`;
}

const withinWeek = diffMs < 7 * 24 * 60 * 60_000;
const formatted = reset.toLocaleString(undefined, {
weekday: withinWeek ? "short" : undefined,
month: withinWeek ? undefined : "short",
day: withinWeek ? undefined : "numeric",
hour: "numeric",
minute: "2-digit",
});
return `Resets ${formatted}`;
}

function UsageWindowRow({ window }: { window: ProviderUsageWindow }) {
const reset = formatReset(window.resetsAt);
return (
<div className="space-y-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-xs font-medium">{window.label}</span>
<span className="text-xs tabular-nums text-muted-foreground">
{window.usedPercent}% used
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn(
"h-full rounded-full",
barColorClass(window.usedPercent),
)}
style={{ width: `${Math.max(window.usedPercent, 2)}%` }}
/>
</div>
{reset ? <p className="text-xs text-muted-foreground">{reset}</p> : null}
</div>
);
}

interface ProviderUsageBlockProps {
config: ProviderConfig;
usage: ProviderUsage | undefined;
isLoading: boolean;
isError: boolean;
}

function ProviderUsageBlock({
config,
usage,
isLoading,
isError,
}: ProviderUsageBlockProps) {
const planLabel = usage?.status === "ok" ? usage.planLabel : null;

return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{config.name}</h3>
{planLabel ? (
<span className="text-xs text-muted-foreground">{planLabel}</span>
) : null}
</div>
<ProviderUsageBody
config={config}
usage={usage}
isLoading={isLoading}
isError={isError}
/>
</div>
);
}

function ProviderUsageBody({
config,
usage,
isLoading,
isError,
}: ProviderUsageBlockProps) {
if (isError) {
return (
<p className="text-xs text-muted-foreground">
Couldn&apos;t load usage right now. Make sure bb&apos;s host is
connected, then refresh.
</p>
);
}
if (!usage) {
return (
<p className="text-xs text-muted-foreground">
{isLoading ? "Loading usage…" : "Usage unavailable."}
</p>
);
}
switch (usage.status) {
case "ok":
if (usage.windows.length === 0) {
return (
<p className="text-xs text-muted-foreground">
No usage limits reported for this plan.
</p>
);
}
return (
<div className="space-y-3">
{usage.windows.map((window) => (
<UsageWindowRow key={window.label} window={window} />
))}
</div>
);
case "unauthenticated":
return (
<p className="text-xs text-muted-foreground">{config.signInHint}</p>
);
case "expired":
return (
<p className="text-xs text-muted-foreground">{config.expiredHint}</p>
);
case "error":
return <p className="text-xs text-muted-foreground">{usage.message}</p>;
default:
return null;
}
}

export function UsageLimitsSettingsSection() {
const usageQuery = useSystemUsageLimits();

return (
<SettingsSection
title="Usage limits"
description="Your Codex and Claude Code subscription usage."
action={
<Button
variant="outline"
size="sm"
disabled={usageQuery.isFetching}
onClick={() => {
void usageQuery.refetch();
}}
>
{usageQuery.isFetching ? "Refreshing…" : "Refresh"}
</Button>
}
>
<div className="divide-y divide-border">
{PROVIDERS.map((config) => (
<div key={config.key} className="py-3 first:pt-0 last:pb-0">
<ProviderUsageBlock
config={config}
usage={usageQuery.data?.[config.key]}
isLoading={usageQuery.isLoading}
isError={usageQuery.isError}
/>
</div>
))}
</div>
</SettingsSection>
);
}
8 changes: 8 additions & 0 deletions apps/app/src/hooks/queries/query-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const SYSTEM_CONFIG_QUERY_KEY = "systemConfig";
export const SYSTEM_EXECUTION_OPTIONS_QUERY_KEY = "systemExecutionOptions";
export const SYSTEM_VERSION_QUERY_KEY = "systemVersion";
export const LOCAL_PROVIDER_CLI_STATUS_QUERY_KEY = "localProviderCliStatus";
export const SYSTEM_USAGE_LIMITS_QUERY_KEY = "systemUsageLimits";
export const LOCAL_PATH_EXISTENCE_QUERY_KEY = "localPathExistence";
export const AUTOMATIONS_QUERY_KEY = "automations";
export const AUTOMATION_DETAIL_QUERY_KEY = "automationDetail";
Expand Down Expand Up @@ -398,6 +399,9 @@ export type LocalProviderCliStatusQueryKey = readonly [
typeof LOCAL_PROVIDER_CLI_STATUS_QUERY_KEY,
number | null,
];
export type SystemUsageLimitsQueryKey = readonly [
typeof SYSTEM_USAGE_LIMITS_QUERY_KEY,
];
export type SystemExecutionOptionsQueryKey = readonly [
typeof SYSTEM_EXECUTION_OPTIONS_QUERY_KEY,
string | null,
Expand Down Expand Up @@ -979,6 +983,10 @@ export function localProviderCliStatusQueryKey(
return [LOCAL_PROVIDER_CLI_STATUS_QUERY_KEY, daemonPort];
}

export function systemUsageLimitsQueryKey(): SystemUsageLimitsQueryKey {
return [SYSTEM_USAGE_LIMITS_QUERY_KEY];
}

export interface SystemExecutionOptionsQueryKeyArgs {
environmentId: string | null;
providerId: string | null;
Expand Down
15 changes: 15 additions & 0 deletions apps/app/src/hooks/queries/system-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import type {
SystemVersionResponse,
} from "@bb/server-contract";
import type { ProviderCliStatusResponse } from "@bb/host-daemon-contract";
import type { ProviderUsageResponse } from "@bb/host-daemon-contract";
import * as api from "@/lib/api";
import { fetchProviderCliStatus } from "@/lib/api-host-daemon";
import { useSystemRealtimeSubscription } from "@/hooks/useRealtimeSubscription";
import {
localProviderCliStatusQueryKey,
systemConfigQueryKey,
systemExecutionOptionsQueryKey,
systemUsageLimitsQueryKey,
systemVersionQueryKey,
} from "./query-keys";
import { requireEnabledQueryArg } from "./query-helpers";
Expand Down Expand Up @@ -99,3 +101,16 @@ export function useLocalProviderCliStatus({
staleTime: Infinity,
});
}

const PROVIDER_USAGE_STALE_TIME_MS = 30_000;

export function useSystemUsageLimits(options?: QueryOptions) {
return useQuery<ProviderUsageResponse>({
queryKey: systemUsageLimitsQueryKey(),
queryFn: ({ signal }) => api.getSystemUsageLimits(signal),
enabled: options?.enabled ?? true,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: PROVIDER_USAGE_STALE_TIME_MS,
});
}
9 changes: 9 additions & 0 deletions apps/app/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import type {
ThreadStoragePathListResponse,
WorkspacePathListResponse,
} from "@bb/server-contract";
import type { ProviderUsageResponse } from "@bb/host-daemon-contract";
import { apiClient, toRelativeUrl } from "./api-server";
import {
buildFilePreview,
Expand Down Expand Up @@ -1609,6 +1610,14 @@ export async function getSystemConfig(
);
}

export async function getSystemUsageLimits(
signal?: AbortSignal,
): Promise<ProviderUsageResponse> {
return request<ProviderUsageResponse>(
apiClient.system["usage-limits"].$get({}, requestOptions(signal)),
);
}

export async function updateExperiments(
experiments: Experiments,
): Promise<Experiments> {
Expand Down
10 changes: 6 additions & 4 deletions apps/app/src/views/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type ThemePreference,
} from "@/hooks/useTheme";
import { useHostDaemon } from "@/hooks/useHostDaemon";
import { UsageLimitsSettingsSection } from "@/components/settings/UsageLimitsSettingsSection";
import { useUpdateExperiments } from "@/hooks/mutations/settings-mutations";
import { useSystemConfig } from "@/hooks/queries/system-queries";
import { useWorkspaceOpenTargets } from "@/hooks/useWorkspaceOpenTargets";
Expand Down Expand Up @@ -385,7 +386,8 @@ const IN_APP_BROWSER_LINK_SETTING_LABEL = "Open links in the in-app browser";
const REWRITE_LOCALHOST_LINKS_SETTING_LABEL = "Rewrite localhost links";
const NAVIGATE_TO_THREAD_AFTER_CREATE_SETTING_LABEL =
"Navigate to threads on creation";
const RICH_TEXT_EDITING_SETTING_LABEL = "Rich text formatting in the prompt box";
const RICH_TEXT_EDITING_SETTING_LABEL =
"Rich text formatting in the prompt box";

export function RootComposeBehaviorSettingsControl({
navigateToThreadAfterCreate,
Expand Down Expand Up @@ -833,6 +835,8 @@ export function SettingsView() {
onThemePreferenceChange={setPreferredTheme}
/>

<UsageLimitsSettingsSection />

<LocalOpenTargetSettingsSection
directoryTargetId={directoryTargetId}
fileTargetId={fileTargetId}
Expand All @@ -843,9 +847,7 @@ export function SettingsView() {
/>

<ExperimentsSettingsSection
claudeCodeMockCliTrafficEnabled={
experiments.claudeCodeMockCliTraffic
}
claudeCodeMockCliTrafficEnabled={experiments.claudeCodeMockCliTraffic}
desktopShellAvailable={desktopShellAvailable}
disabled={
systemConfigQuery.data === undefined ||
Expand Down
2 changes: 2 additions & 0 deletions apps/host-daemon/src/command-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
completeCodexInference,
transcribeCodexVoice,
} from "./codex-chatgpt-client.js";
import { getProviderUsage } from "./provider-usage.js";
import {
ensureThreadRuntime,
startThread,
Expand Down Expand Up @@ -230,6 +231,7 @@ const onlineRpcHandlers: OnlineRpcHandlerMap = {
(options.listModels ?? defaultListModels)({
providerId: command.providerId,
}),
"provider.usage": async () => getProviderUsage(),
"workspace.status": async (command, options) => {
const resolution = await resolveWorkspaceForCommand({
dataDir: options.dataDir,
Expand Down
Loading
Loading