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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const clientSettings: ClientSettings = {
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
legacySidebarEnabled: false,
sidebarUsageLimitsEnabled: true,
timestampFormat: "24-hour",
wordWrap: true,
};
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/sidebar/SidebarChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";
import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
import { SidebarUsageLimits } from "./SidebarUsageLimits";
import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";

export const SidebarChromeHeader = memo(function SidebarChromeHeader({
Expand Down Expand Up @@ -226,6 +227,7 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
<SidebarFooter className="p-[var(--sidebar-content-inset)]">
<SidebarProviderUpdatePill />
<SidebarUpdateArchitectureWarning />
<SidebarUsageLimits />
<SidebarUtilityMenu />
</SidebarFooter>
);
Expand Down
230 changes: 230 additions & 0 deletions apps/web/src/components/sidebar/SidebarUsageLimits.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import type {
ProviderDriverKind,
ServerConfig,
ServerProvider,
ServerProviderUsageLimits,
ServerProviderUsageWindow,
UsageLimitSourceAccount,
UsageProviderKind,
} from "@t3tools/contracts";
import { formatResetsIn, providerLimitsLabel } from "@t3tools/shared/usageLimits";
import { useParams } from "@tanstack/react-router";
import { useMemo } from "react";

import { useComposerDraftStore } from "../../composerDraftStore";
import { useClientSettings } from "../../hooks/useSettings";
import { useServerConfigs, useThread } from "../../state/entities";
import { resolveActiveThreadRouteRef, resolveThreadRouteTarget } from "../../threadRoutes";
import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
import { getDriverOption } from "../settings/providerDriverMeta";
import { PROVIDER_PRESENTATION } from "../usage/usageProviders";

function compactDuration(minutes: number | undefined): string | null {
if (minutes === undefined) return null;
if (minutes % 1_440 === 0) return `${minutes / 1_440}d`;
if (minutes % 60 === 0) return `${minutes / 60}h`;
return `${minutes}m`;
}

function remainingPercent(window: ServerProviderUsageWindow): number {
return Math.max(0, Math.min(100, 100 - window.usedPercent));
}

function compareWindowDuration(
left: ServerProviderUsageWindow,
right: ServerProviderUsageWindow,
): number {
return (
(left.windowDurationMins ?? Number.POSITIVE_INFINITY) -
(right.windowDurationMins ?? Number.POSITIVE_INFINITY)
);
}

function providerColor(driver: ProviderDriverKind): string {
const kind: UsageProviderKind | null =
driver === "codex" ? "codex" : driver === "claudeAgent" ? "claude" : null;
return kind === null ? "var(--sidebar-foreground)" : PROVIDER_PRESENTATION[kind].color;
}

function normalizedEmail(value: string | undefined): string | null {
const email = value?.trim().toLowerCase();
return email ? email : null;
}

function sourceAccountForProvider(
config: ServerConfig,
provider: ServerProvider,
): UsageLimitSourceAccount | null {
const accounts = (config.usageLimitSources ?? []).flatMap((source) => source.accounts);
const sameDriver = accounts.filter((account) => account.driver === provider.driver);
const providerEmail = normalizedEmail(provider.auth.email);
if (providerEmail !== null) {
return sameDriver.find((account) => normalizedEmail(account.email) === providerEmail) ?? null;
}
return sameDriver.length === 1 ? (sameDriver[0] ?? null) : null;
}

function limitsForProvider(
config: ServerConfig,
provider: ServerProvider,
): ServerProviderUsageLimits | null {
if (
provider.usageLimits !== undefined &&
provider.usageLimits.unavailable === undefined &&
provider.usageLimits.windows.length > 0
) {
return provider.usageLimits;
}
const sourceAccount = sourceAccountForProvider(config, provider);
if (
sourceAccount?.usageLimits.unavailable === undefined &&
sourceAccount?.usageLimits.windows.length
) {
return sourceAccount.usageLimits;
}
return null;
}

function windowLabel(window: ServerProviderUsageWindow): string {
const duration = compactDuration(window.windowDurationMins);
return duration === null ? window.label : `${window.label} · ${duration}`;
}

function UsageWindowBar({
window,
color,
now,
}: {
readonly window: ServerProviderUsageWindow;
readonly color: string;
readonly now: number;
}) {
const remaining = remainingPercent(window);
const reset = formatResetsIn(window, now);
return (
<div className="flex flex-col gap-1.5">
<div className="flex min-w-0 items-center gap-2 text-[11px] leading-none">
<span className="min-w-0 flex-1 truncate text-sidebar-muted-foreground">
{windowLabel(window)}
</span>
<span className="shrink-0 font-medium text-sidebar-foreground tabular-nums">
{Math.round(remaining)}% left
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-sidebar-control-surface">
<div
className="h-full rounded-full"
style={{ width: `${remaining}%`, backgroundColor: color }}
/>
</div>
{reset ? (
<span className="text-[10px] leading-none text-sidebar-muted-foreground/70 tabular-nums">
{reset}
</span>
) : null}
</div>
);
}

export function SidebarUsageLimits() {
const enabled = useClientSettings((settings) => settings.sidebarUsageLimitsEnabled);
const routeTarget = useParams({
strict: false,
select: (params) => resolveThreadRouteTarget(params),
});
const draftSession = useComposerDraftStore((store) =>
routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null,
);
const routeThreadRef = useMemo(
() => resolveActiveThreadRouteRef(routeTarget, draftSession),
[draftSession, routeTarget],
);
const activeThread = useThread(routeThreadRef);
const composerDraft = useComposerDraftStore((store) => {
if (routeTarget?.kind === "draft") return store.getComposerDraft(routeTarget.draftId);
if (routeTarget?.kind === "server") return store.getComposerDraft(routeTarget.threadRef);
return null;
});
const serverConfigs = useServerConfigs();
const environmentId =
routeTarget?.kind === "server"
? routeTarget.threadRef.environmentId
: (draftSession?.environmentId ?? null);
const instanceId =
composerDraft?.activeProvider ??
activeThread?.session?.providerInstanceId ??
activeThread?.modelSelection.instanceId ??
null;
const config = environmentId === null ? undefined : serverConfigs.get(environmentId);
const provider = config?.providers.find((candidate) => candidate.instanceId === instanceId);
const limits = enabled && config && provider ? limitsForProvider(config, provider) : null;
if (!limits || !provider) return null;
const now = Date.parse(limits.checkedAt);
const windows = limits.windows.toSorted(compareWindowDuration);
const shortestWindow = windows[0];
if (!shortestWindow) return null;
const color = providerColor(provider.driver);
const providerLabel = providerLimitsLabel(provider, (driver) => getDriverOption(driver)?.label);
const shortestRemaining = remainingPercent(shortestWindow);
const shortestDuration = compactDuration(shortestWindow.windowDurationMins);
const summary = `${providerLabel} ${shortestWindow.label}: ${Math.round(shortestRemaining)}% left`;

return (
<div className="group/sidebar-usage relative">
<div
role="img"
aria-label={summary}
tabIndex={0}
className="flex cursor-default flex-col gap-1.5 rounded-md px-2 py-1.5 outline-none hover:bg-sidebar-row-hover focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="flex min-w-0 items-center gap-1.5 text-[11px] leading-none">
<ProviderInstanceIcon
driverKind={provider.driver}
displayName={providerLabel}
accentColor={provider.accentColor}
showBadge={Boolean(provider.accentColor)}
indicatorBackground="var(--sidebar)"
className="size-3.5 shrink-0"
iconClassName="size-3 text-sidebar-muted-foreground"
/>
<span className="min-w-0 flex-1 truncate text-sidebar-muted-foreground">
{providerLabel}
{shortestDuration ? ` · ${shortestDuration}` : ""}
</span>
<span className="shrink-0 font-medium text-sidebar-foreground tabular-nums">
{Math.round(shortestRemaining)}% left
</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-sidebar-control-surface">
<div
className="h-full rounded-full"
style={{ width: `${shortestRemaining}%`, backgroundColor: color }}
/>
</div>
</div>
<div className="pointer-events-none invisible absolute inset-x-0 bottom-full z-50 pb-1 opacity-0 transition-opacity group-focus-within/sidebar-usage:pointer-events-auto group-focus-within/sidebar-usage:visible group-focus-within/sidebar-usage:opacity-100 group-hover/sidebar-usage:pointer-events-auto group-hover/sidebar-usage:visible group-hover/sidebar-usage:opacity-100">
<div className="flex flex-col gap-3 rounded-lg border border-sidebar-border bg-sidebar p-3 text-sidebar-foreground shadow-lg">
<div className="flex min-w-0 items-center gap-2">
<ProviderInstanceIcon
driverKind={provider.driver}
displayName={providerLabel}
accentColor={provider.accentColor}
showBadge={Boolean(provider.accentColor)}
indicatorBackground="var(--sidebar)"
className="size-4 shrink-0"
iconClassName="size-3.5 text-sidebar-foreground/80"
/>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{providerLabel} limits
</span>
</div>
<div className="flex flex-col gap-3">
{windows.map((window) => (
<UsageWindowBar key={window.id} window={window} color={color} now={now} />
))}
</div>
</div>
</div>
</div>
);
}
26 changes: 25 additions & 1 deletion apps/web/src/components/usage/UsageLimits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ import {
import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
import { Fragment, useState } from "react";

import { usePrimarySettings } from "../../hooks/useSettings";
import {
useClientSettings,
usePrimarySettings,
useUpdateClientSettings,
} from "../../hooks/useSettings";
import { environmentPresentations } from "../../state/presentation";
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
Expand All @@ -42,6 +46,7 @@ import {
AlertDialogTitle,
} from "../ui/alert-dialog";
import { Button } from "../ui/button";
import { Switch } from "../ui/switch";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { PROVIDER_PRESENTATION } from "./usageProviders";

Expand Down Expand Up @@ -397,6 +402,22 @@ const SOURCE_KIND_LABEL: Record<UsageLimitSourceSnapshot["kind"], string> = {
cliproxy: "CLI Proxy",
};

function SidebarLimitsToggle() {
const checked = useClientSettings((settings) => settings.sidebarUsageLimitsEnabled);
const updateSettings = useUpdateClientSettings();
return (
<label className="flex h-8 shrink-0 cursor-pointer items-center gap-2 rounded-md px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground">
<span>Show in sidebar</span>
<Switch
checked={checked}
onCheckedChange={(next) => updateSettings({ sidebarUsageLimitsEnabled: Boolean(next) })}
aria-label="Show limits in sidebar"
size="sm"
/>
</label>
);
}

type LimitsSource = ReturnType<typeof collectLimitSources>[number];

/** Read-only accounts pooled by a configured usage source. */
Expand Down Expand Up @@ -435,6 +456,9 @@ export function UsageLimitsSection() {

return (
<div className="flex flex-col gap-8">
<div className="flex justify-end">
<SidebarLimitsToggle />
</div>
{groups.length === 0 && sources.length === 0 ? (
<p className="text-sm text-muted-foreground">
No provider on a connected environment reports subscription limits.
Expand Down
6 changes: 6 additions & 0 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ provider health-check interval and update live while a turn runs. API-key accoun
subscription windows and say so; that includes a Claude Code that reaches Anthropic through a proxy
via `ANTHROPIC_AUTH_TOKEN`, since the CLI then treats itself as an API-key client.

When an open chat's provider reports subscription limits, the bottom of the sidebar shows the
remaining quota for its shortest window, such as the five-hour session. Hover or focus the indicator
to see every window for that provider without leaving the chat. The indicator is hidden by default;
turn **Show in sidebar** on at the top of the Limits view to display it. The Usage button remains in
the sidebar.

If you pool accounts behind a CLIProxyAPI hub, open **Settings → Providers → Usage providers**
and choose **Add hub**. Select the device that should connect to the hub; its accounts appear on
the Limits view. Remove hubs from the same settings section. Each limits row shows its provider
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ export const ClientSettingsSchema = Schema.Struct({
// old keys, so everyone, including prior beta opt-outs, resets to the new
// default sidebar.
legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
sidebarUsageLimitsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)),
),
Expand Down Expand Up @@ -1201,6 +1202,7 @@ export const ClientSettingsPatch = Schema.Struct({
proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean),
showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean),
legacySidebarEnabled: Schema.optionalKey(Schema.Boolean),
sidebarUsageLimitsEnabled: Schema.optionalKey(Schema.Boolean),
sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),
sidebarProjectGroupingOverrides: Schema.optionalKey(
Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode),
Expand Down
Loading