authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-03 23:56:23-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 23:56:23-07:00
logf1e90e388b86fe4b007a55c0e685a1fa878115e6
tree0dbc112c02ab0e9dc05ba70241254d84075e10a6
parentcaab2fdbac041ac2e851ad4fa3ac4a40a1d4a8f6
signature Signed by PGP key B5690EEEBB952194

refactor(web): move usage provider controls to settings (#9599)


10 files changed, 332 insertions(+), 390 deletions(-)

apps/mobile/src/features/settings/components/SettingsSection.tsx+4-2
......@@ -4,14 +4,16 @@ import { View } from "react-native";
44import { AppText as Text } from "../../../components/AppText";
55
66export function SettingsSection(props: {
7 readonly title: string;
7 readonly title?: string;
88 readonly children: ReactNode;
99 /** Force the grouped card background; Android otherwise lists options flat. */
1010 readonly card?: boolean;
1111}) {
1212 return (
1313 <View className="gap-2">
14 <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text>
14 {props.title ? (
15 <Text className="px-2 text-sm font-t3-medium text-foreground-muted">{props.title}</Text>
16 ) : null}
1517 <View
1618 className={
1719 props.card
apps/mobile/src/features/usage/UsageLimitsSection.tsx+1-1
......@@ -255,7 +255,7 @@ export function UsageLimitsSection() {
255255 return (
256256 <>
257257 {sources.map((source) => (
258 <SettingsSection key={source.key} title={source.label} card>
258 <SettingsSection key={source.key} card>
259259 {source.error ? (
260260 <Text className="p-4 text-sm text-foreground-muted">{source.error}</Text>
261261 ) : source.accounts.length === 0 ? (
apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx created+157
......@@ -0,0 +1,157 @@
1import { type EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts";
2import { useState } from "react";
3
4import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";
5import { Button } from "../ui/button";
6import {
7 Dialog,
8 DialogDescription,
9 DialogFooter,
10 DialogHeader,
11 DialogPanel,
12 DialogPopup,
13 DialogTitle,
14} from "../ui/dialog";
15import { Input } from "../ui/input";
16import { Label } from "../ui/label";
17
18/**
19 * Stable per hub and readable in settings.json. Dots and dashes in the host
20 * are kept so `foo-bar.com` and `foo.bar.com` do not collide; anything else
21 * (a port's colon, a path) is folded to a dash.
22 */
23function sourceIdFromUrl(url: string): UsageLimitSourceId {
24 let host = url;
25 try {
26 host = new URL(url).host;
27 } catch {
28 // Keep the raw text; the server reports the bad URL on its row.
29 }
30 const slug = host
31 .toLowerCase()
32 .replace(/[^a-z0-9.-]+/g, "-")
33 .replace(/^-+|-+$/g, "");
34 return UsageLimitSourceId.make(`cliproxy-${slug || "hub"}`);
35}
36
37/**
38 * Adds a CLIProxyAPI hub from provider settings on one environment. The
39 * management key is sent once and kept in that server's secret store;
40 * settings only ever carry a redaction marker for it afterwards.
41 */
42export function AddUsageLimitSourceDialog({
43 open,
44 onOpenChange,
45 environmentId,
46 environmentLabel,
47}: {
48 readonly open: boolean;
49 readonly onOpenChange: (open: boolean) => void;
50 readonly environmentId: EnvironmentId;
51 readonly environmentLabel: string;
52}) {
53 const updateSettings = useUpdateEnvironmentSettings(environmentId);
54 const [label, setLabel] = useState("");
55 const [url, setUrl] = useState("");
56 const [managementKey, setManagementKey] = useState("");
57 const trimmedUrl = url.trim();
58 const canSave = trimmedUrl.length > 0 && managementKey.trim().length > 0;
59
60 const reset = () => {
61 setLabel("");
62 setUrl("");
63 setManagementKey("");
64 };
65
66 const save = () => {
67 if (!canSave) return;
68 const id = sourceIdFromUrl(trimmedUrl);
69 // The patch names only this entry; the server merges it into its map.
70 updateSettings({
71 usageLimitSources: {
72 [id]: {
73 kind: "cliproxy",
74 ...(label.trim() ? { label: label.trim() } : {}),
75 url: trimmedUrl,
76 managementKey: managementKey.trim(),
77 enabled: true,
78 },
79 },
80 });
81 reset();
82 onOpenChange(false);
83 };
84
85 return (
86 <Dialog
87 open={open}
88 onOpenChange={(next) => {
89 if (!next) reset();
90 onOpenChange(next);
91 }}
92 >
93 <DialogPopup className="max-w-md">
94 <DialogHeader>
95 <DialogTitle>Add a CLIProxyAPI hub</DialogTitle>
96 <DialogDescription>
97 Show the quota of every account the hub pools, next to the providers on{" "}
98 {environmentLabel}. The key stays on that server.
99 </DialogDescription>
100 </DialogHeader>
101 <DialogPanel>
102 <form
103 className="grid gap-4"
104 onSubmit={(event) => {
105 event.preventDefault();
106 save();
107 }}
108 >
109 <div className="grid gap-1.5">
110 <Label htmlFor="usage-source-url">Hub URL</Label>
111 <Input
112 id="usage-source-url"
113 placeholder="https://hub.example.ts.net:8318"
114 value={url}
115 onChange={(event) => setUrl(event.target.value)}
116 autoFocus
117 />
118 </div>
119 <div className="grid gap-1.5">
120 <Label htmlFor="usage-source-key">Management key</Label>
121 <Input
122 id="usage-source-key"
123 type="password"
124 autoComplete="off"
125 value={managementKey}
126 onChange={(event) => setManagementKey(event.target.value)}
127 />
128 </div>
129 <div className="grid gap-1.5">
130 <Label htmlFor="usage-source-label">Label (optional)</Label>
131 <Input
132 id="usage-source-label"
133 placeholder="Defaults to the hub's host name"
134 value={label}
135 onChange={(event) => setLabel(event.target.value)}
136 />
137 </div>
138 </form>
139 </DialogPanel>
140 <DialogFooter variant="bare">
141 <Button
142 variant="outline"
143 onClick={() => {
144 reset();
145 onOpenChange(false);
146 }}
147 >
148 Cancel
149 </Button>
150 <Button onClick={save} disabled={!canSave}>
151 Add hub
152 </Button>
153 </DialogFooter>
154 </DialogPopup>
155 </Dialog>
156 );
157}
apps/web/src/components/settings/ProviderSettingsPanel.tsx+11-1
......@@ -68,6 +68,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
6868import { stackedThreadToast, toastManager } from "../ui/toast";
6969import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
7070import { ProviderInstanceCard } from "./ProviderInstanceCard";
71import { UsageProviderSettings } from "./UsageProviderSettings";
7172import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection";
7273import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
7374import { providerSettingsTabClassName } from "./providerSettingsTabs";
......@@ -241,7 +242,8 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
241242 )?.environmentId;
242243 useEffect(() => {
243244 if (
244 searchTargetId === searchableSetting("provider-health-check-interval").id &&
245 (searchTargetId === searchableSetting("provider-health-check-interval").id ||
246 searchTargetId === searchableSetting("usage-providers").id) &&
245247 !selectedEnvironmentCanRenderSettings &&
246248 searchableEnvironmentId !== undefined
247249 ) {
......@@ -997,6 +999,14 @@ export function EnvironmentProviderSettings({
997999 </div>
9981000 </SettingsSection>
9991001
1002 <UsageProviderSettings
1003 key={environmentId}
1004 environmentId={environmentId}
1005 environmentLabel={environmentLabel}
1006 sources={settings.usageLimitSources}
1007 readOnly={readOnly}
1008 />
1009
10001010 <SettingsSection title="Advanced">
10011011 <SettingsRow
10021012 id={searchableSetting("provider-health-check-interval").id}
apps/web/src/components/settings/UsageProviderSettings.tsx created+130
......@@ -0,0 +1,130 @@
1import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts";
2import { PlusIcon } from "lucide-react";
3import { useState } from "react";
4
5import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";
6import {
7 AlertDialog,
8 AlertDialogClose,
9 AlertDialogDescription,
10 AlertDialogFooter,
11 AlertDialogHeader,
12 AlertDialogPopup,
13 AlertDialogTitle,
14} from "../ui/alert-dialog";
15import { Button } from "../ui/button";
16import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog";
17import { searchableSetting } from "./settingsSearch";
18import { SettingsRow, SettingsSection } from "./settingsLayout";
19
20/** Hub management follows the selected device and access rules of provider settings. */
21export function UsageProviderSettings({
22 environmentId,
23 environmentLabel,
24 sources,
25 readOnly,
26}: {
27 readonly environmentId: EnvironmentId;
28 readonly environmentLabel: string;
29 readonly sources: UnifiedSettings["usageLimitSources"];
30 readonly readOnly: boolean;
31}) {
32 const updateSettings = useUpdateEnvironmentSettings(environmentId);
33 const [adding, setAdding] = useState(false);
34 const entries = Object.entries(sources);
35
36 return (
37 <>
38 <SettingsSection
39 {...searchableSetting("usage-providers")}
40 description="Connect a CLIProxyAPI hub to show its accounts on Usage → Limits."
41 headerAction={
42 !readOnly ? (
43 <Button size="xs" variant="outline" onClick={() => setAdding(true)}>
44 <PlusIcon className="size-3" aria-hidden />
45 Add hub
46 </Button>
47 ) : null
48 }
49 >
50 {entries.length === 0 ? (
51 <SettingsRow title="No usage providers configured." />
52 ) : (
53 entries.map(([id, source]) => {
54 const label = source.label?.trim() || source.url;
55 return (
56 <SettingsRow
57 key={id}
58 title={label}
59 description={
60 <span className="break-all">
61 CLI Proxy{source.enabled ? "" : " · Disabled"}
62 {label !== source.url ? ` · ${source.url}` : ""}
63 </span>
64 }
65 control={
66 !readOnly ? (
67 <RemoveUsageProviderButton
68 label={label}
69 onConfirm={() => updateSettings({ usageLimitSources: { [id]: null } })}
70 />
71 ) : null
72 }
73 />
74 );
75 })
76 )}
77 </SettingsSection>
78 {adding && !readOnly ? (
79 <AddUsageLimitSourceDialog
80 open
81 onOpenChange={setAdding}
82 environmentId={environmentId}
83 environmentLabel={environmentLabel}
84 />
85 ) : null}
86 </>
87 );
88}
89
90/** Removing a hub deletes its stored management key, so it requires confirmation. */
91function RemoveUsageProviderButton({
92 label,
93 onConfirm,
94}: {
95 readonly label: string;
96 readonly onConfirm: () => void;
97}) {
98 const [open, setOpen] = useState(false);
99 return (
100 <>
101 <Button size="xs" variant="ghost" onClick={() => setOpen(true)}>
102 Remove
103 </Button>
104 <AlertDialog open={open} onOpenChange={setOpen}>
105 <AlertDialogPopup>
106 <AlertDialogHeader>
107 <AlertDialogTitle>Remove {label}?</AlertDialogTitle>
108 <AlertDialogDescription>
109 The hub's management key is deleted from this server. Its accounts leave the Limits
110 view; the hub itself is untouched. Add it again with the URL and key to bring them
111 back.
112 </AlertDialogDescription>
113 </AlertDialogHeader>
114 <AlertDialogFooter>
115 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
116 <Button
117 variant="destructive"
118 onClick={() => {
119 setOpen(false);
120 onConfirm();
121 }}
122 >
123 Remove hub
124 </Button>
125 </AlertDialogFooter>
126 </AlertDialogPopup>
127 </AlertDialog>
128 </>
129 );
130}
apps/web/src/components/settings/settingsSearch.test.ts+10
......@@ -101,6 +101,16 @@ describe("searchSettings", () => {
101101 ]);
102102 });
103103
104 it.each(["usage providers", "CLIProxyAPI", "CLI proxy hub", "management key"])(
105 "finds usage-provider management by %s",
106 (query) => {
107 expect(searchSettings(query)[0]).toMatchObject({
108 id: "usage-providers",
109 to: "/settings/providers",
110 });
111 },
112 );
113
104114 it("returns no results for an empty query", () => {
105115 expect(searchSettings(" ", ITEMS)).toEqual([]);
106116 });
apps/web/src/components/settings/settingsSearch.ts+9
......@@ -324,6 +324,15 @@ export const SETTINGS_SEARCH_ITEMS = [
324324 "agents cli codex claude cursor grok opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact",
325325 ],
326326 },
327 {
328 id: "usage-providers",
329 title: "Usage providers",
330 to: "/settings/providers",
331 searchTerms: [
332 "usage sources CLIProxyAPI CLI proxy hub quota subscription limits management key add remove",
333 ],
334 providerSettingsOnly: true,
335 },
327336 {
328337 id: "provider-health-check-interval",
329338 title: "Health check interval",
apps/web/src/components/usage/AddUsageLimitSourceDialog.tsx deleted-157
......@@ -1,157 +0,0 @@
1import { type EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts";
2import { useState } from "react";
3
4import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";
5import { Button } from "../ui/button";
6import {
7 Dialog,
8 DialogDescription,
9 DialogFooter,
10 DialogHeader,
11 DialogPanel,
12 DialogPopup,
13 DialogTitle,
14} from "../ui/dialog";
15import { Input } from "../ui/input";
16import { Label } from "../ui/label";
17
18/**
19 * Stable per hub and readable in settings.json. Dots and dashes in the host
20 * are kept so `foo-bar.com` and `foo.bar.com` do not collide; anything else
21 * (a port's colon, a path) is folded to a dash.
22 */
23function sourceIdFromUrl(url: string): UsageLimitSourceId {
24 let host = url;
25 try {
26 host = new URL(url).host;
27 } catch {
28 // Keep the raw text; the server reports the bad URL on its row.
29 }
30 const slug = host
31 .toLowerCase()
32 .replace(/[^a-z0-9.-]+/g, "-")
33 .replace(/^-+|-+$/g, "");
34 return UsageLimitSourceId.make(`cliproxy-${slug || "hub"}`);
35}
36
37/**
38 * Adds a CLIProxyAPI hub as a usage-limit source on one environment. The
39 * management key is sent once and kept in that server's secret store;
40 * settings only ever carry a redaction marker for it afterwards.
41 */
42export function AddUsageLimitSourceDialog({
43 open,
44 onOpenChange,
45 environmentId,
46 environmentLabel,
47}: {
48 readonly open: boolean;
49 readonly onOpenChange: (open: boolean) => void;
50 readonly environmentId: EnvironmentId;
51 readonly environmentLabel: string;
52}) {
53 const updateSettings = useUpdateEnvironmentSettings(environmentId);
54 const [label, setLabel] = useState("");
55 const [url, setUrl] = useState("");
56 const [managementKey, setManagementKey] = useState("");
57 const trimmedUrl = url.trim();
58 const canSave = trimmedUrl.length > 0 && managementKey.trim().length > 0;
59
60 const reset = () => {
61 setLabel("");
62 setUrl("");
63 setManagementKey("");
64 };
65
66 const save = () => {
67 if (!canSave) return;
68 const id = sourceIdFromUrl(trimmedUrl);
69 // The patch names only this entry; the server merges it into its map.
70 updateSettings({
71 usageLimitSources: {
72 [id]: {
73 kind: "cliproxy",
74 ...(label.trim() ? { label: label.trim() } : {}),
75 url: trimmedUrl,
76 managementKey: managementKey.trim(),
77 enabled: true,
78 },
79 },
80 });
81 reset();
82 onOpenChange(false);
83 };
84
85 return (
86 <Dialog
87 open={open}
88 onOpenChange={(next) => {
89 if (!next) reset();
90 onOpenChange(next);
91 }}
92 >
93 <DialogPopup className="max-w-md">
94 <DialogHeader>
95 <DialogTitle>Add a CLIProxyAPI hub</DialogTitle>
96 <DialogDescription>
97 Show the quota of every account the hub pools, next to the providers on{" "}
98 {environmentLabel}. The key stays on that server.
99 </DialogDescription>
100 </DialogHeader>
101 <DialogPanel>
102 <form
103 className="grid gap-4"
104 onSubmit={(event) => {
105 event.preventDefault();
106 save();
107 }}
108 >
109 <div className="grid gap-1.5">
110 <Label htmlFor="usage-source-url">Hub URL</Label>
111 <Input
112 id="usage-source-url"
113 placeholder="https://hub.example.ts.net:8318"
114 value={url}
115 onChange={(event) => setUrl(event.target.value)}
116 autoFocus
117 />
118 </div>
119 <div className="grid gap-1.5">
120 <Label htmlFor="usage-source-key">Management key</Label>
121 <Input
122 id="usage-source-key"
123 type="password"
124 autoComplete="off"
125 value={managementKey}
126 onChange={(event) => setManagementKey(event.target.value)}
127 />
128 </div>
129 <div className="grid gap-1.5">
130 <Label htmlFor="usage-source-label">Label (optional)</Label>
131 <Input
132 id="usage-source-label"
133 placeholder="Defaults to the hub's host name"
134 value={label}
135 onChange={(event) => setLabel(event.target.value)}
136 />
137 </div>
138 </form>
139 </DialogPanel>
140 <DialogFooter variant="bare">
141 <Button
142 variant="outline"
143 onClick={() => {
144 reset();
145 onOpenChange(false);
146 }}
147 >
148 Cancel
149 </Button>
150 <Button onClick={save} disabled={!canSave}>
151 Add hub
152 </Button>
153 </DialogFooter>
154 </DialogPopup>
155 </Dialog>
156 );
157}
apps/web/src/components/usage/UsageLimits.tsx+6-227
......@@ -1,5 +1,5 @@
11import {
2 EnvironmentId,
2 type EnvironmentId,
33 type ProviderConsumeResetCreditOutcome,
44 ProviderInstanceId,
55 ServerProvider,
......@@ -21,28 +21,16 @@ import {
2121 paceOf,
2222 providerLimitsLabel,
2323} from "@t3tools/shared/usageLimits";
24import { GaugeIcon, PlusIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
24import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
2525import { Fragment, useState } from "react";
2626
27import { isElectron } from "../../env";
28import { usePrimarySessionState } from "../../environments/primary";
29import { usePrimarySettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings";
30import {
31 type EnvironmentPresentation,
32 useEnvironments,
33 usePrimaryEnvironmentId,
34} from "../../state/environments";
35import { useEnvironmentSessionState } from "../../state/session";
27import { usePrimarySettings } from "../../hooks/useSettings";
3628import { environmentPresentations } from "../../state/presentation";
3729import { serverEnvironment } from "../../state/server";
3830import { useAtomCommand } from "../../state/use-atom-command";
3931import { formatUpcomingTimestamp } from "../../timestampFormat";
4032import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
4133import { getDriverOption } from "../settings/providerDriverMeta";
42import {
43 resolvePrimaryOperateAccess,
44 resolveRemoteOperateAccess,
45} from "../settings/ProviderSettingsPanel.logic";
4634import { RedactedSensitiveText } from "../settings/RedactedSensitiveText";
4735import {
4836 AlertDialog,
......@@ -54,9 +42,7 @@ import {
5442 AlertDialogTitle,
5543} from "../ui/alert-dialog";
5644import { Button } from "../ui/button";
57import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
5845import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
59import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog";
6046import { PROVIDER_PRESENTATION } from "./usageProviders";
6147
6248const PACE: Record<LimitPace, { readonly label: string; readonly icon: typeof GaugeIcon }> = {
......@@ -407,79 +393,17 @@ function SourceAccountLimits({
407393 );
408394}
409395
410/**
411 * Accounts a configured source (a CLIProxyAPI hub) pools, grouped under the
412 * source's name. Unlike provider rows these are read-only: nothing on this
413 * environment can run a turn against them.
414 */
415396const SOURCE_KIND_LABEL: Record<UsageLimitSourceSnapshot["kind"], string> = {
416397 cliproxy: "CLI Proxy",
417398};
418399
419400type LimitsSource = ReturnType<typeof collectLimitSources>[number];
420401
421/**
422 * Removing a hub also deletes its management key from the server, so it
423 * asks first and says so. A bare icon that acted on click was too easy to
424 * hit while reaching for the row beside it.
425 */
426function RemoveSourceButton({
427 source,
428 onConfirm,
429}: {
430 readonly source: UsageLimitSourceSnapshot;
431 readonly onConfirm: () => void;
432}) {
433 const [open, setOpen] = useState(false);
434 return (
435 <>
436 <Button size="xs" variant="ghost" onClick={() => setOpen(true)}>
437 Remove
438 </Button>
439 <AlertDialog open={open} onOpenChange={setOpen}>
440 <AlertDialogPopup>
441 <AlertDialogHeader>
442 <AlertDialogTitle>Remove {source.label}?</AlertDialogTitle>
443 <AlertDialogDescription>
444 The hub's management key is deleted from this server. Its accounts leave the Limits
445 view; the hub itself is untouched. Add it again with the URL and key to bring them
446 back.
447 </AlertDialogDescription>
448 </AlertDialogHeader>
449 <AlertDialogFooter>
450 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
451 <Button
452 variant="destructive"
453 onClick={() => {
454 setOpen(false);
455 onConfirm();
456 }}
457 >
458 Remove hub
459 </Button>
460 </AlertDialogFooter>
461 </AlertDialogPopup>
462 </AlertDialog>
463 </>
464 );
465}
466
467function SourceLimits({
468 source,
469 now,
470 onRemove,
471}: {
472 readonly source: LimitsSource;
473 readonly now: number;
474 readonly onRemove: (() => void) | null;
475}) {
402/** Read-only accounts pooled by a configured usage source. */
403function SourceLimits({ source, now }: { readonly source: LimitsSource; readonly now: number }) {
476404 const kind = SOURCE_KIND_LABEL[source.kind];
477405 return (
478406 <div className="flex flex-col gap-6">
479 <div className="flex items-center justify-between gap-3">
480 <h2 className="text-xs tracking-wide text-muted-foreground uppercase">{source.label}</h2>
481 {onRemove ? <RemoveSourceButton source={source} onConfirm={onRemove} /> : null}
482 </div>
483407 {source.error ? (
484408 <span className="text-xs text-muted-foreground">{source.error}</span>
485409 ) : source.accounts.length === 0 ? (
......@@ -497,49 +421,6 @@ function SourceLimits({
497421 );
498422}
499423
500/**
501 * Whether this client's credential may write settings on an environment,
502 * resolved the way Settings → Providers does: the desktop app owns its
503 * primary outright; a browser session checks the scopes it was granted;
504 * a remote environment reports scopes over its own session endpoint.
505 */
506function useCanOperateEnvironment(environment: EnvironmentPresentation | null): boolean {
507 const isPrimary = environment?.entry.target._tag === "PrimaryConnectionTarget";
508 const primarySession = usePrimarySessionState();
509 const remoteSession = useEnvironmentSessionState(
510 environment?.environmentId ?? EnvironmentId.make("none"),
511 );
512 if (environment === null || environment.connection.phase !== "connected") return false;
513 if (isPrimary && isElectron) return true;
514 const access = isPrimary
515 ? resolvePrimaryOperateAccess({
516 isPrimary: true,
517 hasDesktopBridge: false,
518 session: primarySession.data,
519 isPending: primarySession.isPending,
520 hasError: primarySession.error !== null,
521 })
522 : resolveRemoteOperateAccess({
523 session: remoteSession.data,
524 isPending: remoteSession.isPending,
525 hasError: remoteSession.hasError,
526 });
527 return access === "granted";
528}
529
530/** One source with a remove control bound to the environment it lives in. */
531function SourceLimitsRow({ source, now }: { readonly source: LimitsSource; readonly now: number }) {
532 const updateSettings = useUpdateEnvironmentSettings(source.environmentId);
533 const { environments } = useEnvironments();
534 const environment =
535 environments.find((entry) => entry.environmentId === source.environmentId) ?? null;
536 const canOperate = useCanOperateEnvironment(environment);
537 // The patch names only this entry, so two edits in flight cannot clobber
538 // each other's map.
539 const remove = () => updateSettings({ usageLimitSources: { [source.id]: null } });
540 return <SourceLimits source={source} now={now} onRemove={canOperate ? remove : null} />;
541}
542
543424/**
544425 * Subscription quota windows from every connected environment's providers.
545426 * Countdowns anchor to render time rather than ticking: a live clock would
......@@ -549,108 +430,18 @@ export function UsageLimitsSection() {
549430 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
550431 const groups = collectLimitsGroups(presentations);
551432 const sources = collectLimitSources(presentations);
552 const primaryEnvironmentId = usePrimaryEnvironmentId();
553 const { environments } = useEnvironments();
554 const [adding, setAdding] = useState(false);
555433 // Anchored once per mount on purpose: countdowns must not tick (see below).
556434 const [now] = useState(() => Date.now());
557435
558 // Sources live in one environment's settings. Writing them needs only the
559 // operate scope, like any provider control, so a T3 Connect client can add
560 // a hub to whichever environment it is connected to: the primary when there
561 // is one, else the first connected environment, with a picker for more.
562 const connected = environments.filter(
563 (environment) => environment.connection.phase === "connected",
564 );
565 const [pickedEnvironmentId, setPickedEnvironmentId] = useState<EnvironmentId | null>(null);
566 const targetEnvironment =
567 (pickedEnvironmentId !== null
568 ? connected.find((environment) => environment.environmentId === pickedEnvironmentId)
569 : undefined) ??
570 (primaryEnvironmentId !== null
571 ? connected.find((environment) => environment.environmentId === primaryEnvironmentId)
572 : undefined) ??
573 connected[0] ??
574 null;
575 const canOperateTarget = useCanOperateEnvironment(targetEnvironment);
576
577436 return (
578437 <div className="flex flex-col gap-8">
579 {/* Sources first: they are the thing a user configures here, so the
580 control to add one sits at the top rather than after every row. */}
581 <div className="flex items-center justify-between gap-3">
582 <div className="flex flex-col gap-1">
583 <h2 className="text-sm font-medium text-foreground">Usage sources</h2>
584 <p className="text-xs text-muted-foreground">
585 Quota from a CLIProxyAPI hub shows beside the providers signed in on this machine.
586 </p>
587 </div>
588 {/* The picker stays whenever several environments are connected, so
589 a read-only default target does not hide the way to an operable
590 one; only the button follows the picked target's access. */}
591 {targetEnvironment ? (
592 <div className="flex items-center gap-2">
593 {connected.length > 1 ? (
594 <Select
595 value={targetEnvironment.environmentId}
596 onValueChange={(value) => {
597 if (value !== null) setPickedEnvironmentId(EnvironmentId.make(value));
598 }}
599 >
600 <SelectTrigger
601 aria-label="Environment to add the hub to"
602 size="compact"
603 variant="ghost"
604 className="w-auto min-w-0"
605 >
606 <SelectValue>{targetEnvironment.label}</SelectValue>
607 </SelectTrigger>
608 <SelectPopup align="end" alignItemWithTrigger={false}>
609 {connected.map((environment) => (
610 <SelectItem key={environment.environmentId} value={environment.environmentId}>
611 {environment.label}
612 </SelectItem>
613 ))}
614 </SelectPopup>
615 </Select>
616 ) : null}
617 {canOperateTarget ? (
618 <Button size="xs" variant="outline" onClick={() => setAdding(true)}>
619 <PlusIcon className="size-3" aria-hidden />
620 Add hub
621 </Button>
622 ) : (
623 <Tooltip>
624 <TooltipTrigger
625 render={
626 <span
627 tabIndex={0}
628 className="rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
629 />
630 }
631 >
632 <span className="inline-flex" inert>
633 <Button size="xs" variant="outline" disabled>
634 <PlusIcon className="size-3" aria-hidden />
635 Add hub
636 </Button>
637 </span>
638 </TooltipTrigger>
639 <TooltipPopup side="top" className="max-w-72">
640 Your session cannot change settings on {targetEnvironment.label}.
641 </TooltipPopup>
642 </Tooltip>
643 )}
644 </div>
645 ) : null}
646 </div>
647438 {groups.length === 0 && sources.length === 0 ? (
648439 <p className="text-sm text-muted-foreground">
649440 No provider on a connected environment reports subscription limits.
650441 </p>
651442 ) : null}
652443 {sources.map((source) => (
653 <SourceLimitsRow key={source.key} source={source} now={now} />
444 <SourceLimits key={source.key} source={source} now={now} />
654445 ))}
655446 {groups.map((group) => (
656447 <div key={group.environmentId} className="flex flex-col gap-6">
......@@ -669,18 +460,6 @@ export function UsageLimitsSection() {
669460 ))}
670461 </div>
671462 ))}
672 {targetEnvironment && canOperateTarget ? (
673 // Keyed on the target: if it disconnects or the primary changes while
674 // the dialog is open, a fresh dialog mounts empty rather than carrying
675 // a typed key over to a different environment.
676 <AddUsageLimitSourceDialog
677 key={targetEnvironment.environmentId}
678 open={adding}
679 onOpenChange={setAdding}
680 environmentId={targetEnvironment.environmentId}
681 environmentLabel={targetEnvironment.label}
682 />
683 ) : null}
684463 </div>
685464 );
686465}
docs/user/usage.md+4-2
......@@ -18,8 +18,10 @@ provider health-check interval and update live while a turn runs. API-key accoun
1818subscription windows and say so; that includes a Claude Code that reaches Anthropic through a proxy
1919via `ANTHROPIC_AUTH_TOKEN`, since the CLI then treats itself as an API-key client.
2020
21If you pool accounts behind a CLIProxyAPI hub, **Add hub** on the Limits view shows the accounts
22the hub manages. Each row shows its provider and instance name, or a small _CLI Proxy_ label for
21If you pool accounts behind a CLIProxyAPI hub, open **Settings → Providers → Usage providers**
22and choose **Add hub**. Select the device that should connect to the hub; its accounts appear on
23the Limits view. Remove hubs from the same settings section. Each limits row shows its provider
24and instance name, or a small _CLI Proxy_ label for
2325hub accounts. When a connected provider reports limits for the same provider and email, its row
2426replaces the hub copy, keeping details such as banked reset credits. The hub copy remains visible
2527if the connected provider cannot report limits. Enter the hub's URL and management key; the key