authorgravatar for 75276669+Gigioxx@users.noreply.github.comGuillermo Casanova <75276669+Gigioxx@users.noreply.github.com> 2026-09-26 00:41:48-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 22:41:48-05:00
log94c42162b443072ca59a0733c02ee54229a61fef
tree7543a044f0a4f8c9a318ced4d5cbc63154c58426
parent999161ef844a7a38c8a6d08195c1240759169832
signature Signed by PGP key B5690EEEBB952194

fix(usage): hide the Cursor keychain prompt when Cursor isn't set up (#13714)


7 files changed, 88 insertions(+), 10 deletions(-)

apps/mobile/src/features/usage/UsageRouteScreen.tsx+2-2
...@@ -101,8 +101,8 @@ export function UsageRouteScreen() {...@@ -101,8 +101,8 @@ export function UsageRouteScreen() {
101 );101 );
102 const isFocused = useIsFocused();102 const isFocused = useIsFocused();
103 const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits");103 const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits");
104 const cursorAccessEnvironments = selectedEnvironments.filter((environment) =>104 const cursorAccessEnvironments = selectedEnvironments.filter(
105 environment.summary?.sources.some((source) => source.action === "enableCursorKeychain"),105 (environment) => environment.needsCursorKeychainAccess,
106 );106 );
107 const refreshAfterCursorEnable = () => {107 const refreshAfterCursorEnable = () => {
108 void refresh();108 void refresh();
apps/mobile/src/state/usage.ts+8-2
...@@ -16,7 +16,7 @@ import {...@@ -16,7 +16,7 @@ import {
16 type UsageSummary,16 type UsageSummary,
17 type UsageSummaryInput,17 type UsageSummaryInput,
18} from "@t3tools/contracts";18} from "@t3tools/contracts";
19import { refreshUsage } from "@t3tools/client-runtime/state/usage";19import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";
20import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";20import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
21import * as Option from "effect/Option";21import * as Option from "effect/Option";
22import { AsyncResult, Atom } from "effect/unstable/reactivity";22import { AsyncResult, Atom } from "effect/unstable/reactivity";
...@@ -33,6 +33,7 @@ export interface EnvironmentUsageStatus {...@@ -33,6 +33,7 @@ export interface EnvironmentUsageStatus {
33 readonly isConnected: boolean;33 readonly isConnected: boolean;
34 readonly error: string | null;34 readonly error: string | null;
35 readonly summary: UsageSummary | null;35 readonly summary: UsageSummary | null;
36 readonly needsCursorKeychainAccess: boolean;
36}37}
3738
38/**39/**
...@@ -50,13 +51,18 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>...@@ -50,13 +51,18 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
50 const statuses: EnvironmentUsageStatus[] = [];51 const statuses: EnvironmentUsageStatus[] = [];
51 for (const [environmentId, presentation] of presentations) {52 for (const [environmentId, presentation] of presentations) {
52 const result = get(serverEnvironment.usageSummary({ environmentId, input }));53 const result = get(serverEnvironment.usageSummary({ environmentId, input }));
54 const summary = Option.getOrNull(AsyncResult.value(result));
53 statuses.push({55 statuses.push({
54 environmentId,56 environmentId,
55 label: presentation.entry.target.label,57 label: presentation.entry.target.label,
56 isPending: result.waiting,58 isPending: result.waiting,
57 isConnected: presentation.connection.phase === "connected",59 isConnected: presentation.connection.phase === "connected",
58 error: result._tag === "Failure" ? "This environment could not report usage." : null,60 error: result._tag === "Failure" ? "This environment could not report usage." : null,
59 summary: Option.getOrNull(AsyncResult.value(result)),61 summary,
62 needsCursorKeychainAccess: needsCursorKeychainAccess(
63 summary,
64 get(serverEnvironment.providersValueAtom(environmentId)),
65 ),
60 });66 });
61 }67 }
62 return statuses;68 return statuses;
apps/web/src/components/usage/UsagePage.tsx+2-2
...@@ -121,8 +121,8 @@ export function UsagePage() {...@@ -121,8 +121,8 @@ export function UsagePage() {
121 selectedEnvironmentIds,121 selectedEnvironmentIds,
122 );122 );
123 const presentations = useAtomValue(environmentPresentations.presentationsAtom);123 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
124 const cursorAccessEnvironments = selectedEnvironments.filter((environment) =>124 const cursorAccessEnvironments = selectedEnvironments.filter(
125 environment.summary?.sources.some((source) => source.action === "enableCursorKeychain"),125 (environment) => environment.needsCursorKeychainAccess,
126 );126 );
127 const sourceMessages = [127 const sourceMessages = [
128 ...new Set(128 ...new Set(
apps/web/src/state/usage.test.tsx+1
...@@ -23,6 +23,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU...@@ -23,6 +23,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU
23 label: id,23 label: id,
24 isPending: cost === null,24 isPending: cost === null,
25 error: null,25 error: null,
26 needsCursorKeychainAccess: false,
26 summary:27 summary:
27 cost === null28 cost === null
28 ? null29 ? null
apps/web/src/state/usage.ts+8-2
...@@ -13,7 +13,7 @@ import {...@@ -13,7 +13,7 @@ import {
13 type UsageSummary,13 type UsageSummary,
14 type UsageSummaryInput,14 type UsageSummaryInput,
15} from "@t3tools/contracts";15} from "@t3tools/contracts";
16import { refreshUsage } from "@t3tools/client-runtime/state/usage";16import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";
17import * as Option from "effect/Option";17import * as Option from "effect/Option";
18import { AsyncResult, Atom } from "effect/unstable/reactivity";18import { AsyncResult, Atom } from "effect/unstable/reactivity";
19import { useCallback, useMemo } from "react";19import { useCallback, useMemo } from "react";
...@@ -29,6 +29,7 @@ export interface EnvironmentUsageStatus {...@@ -29,6 +29,7 @@ export interface EnvironmentUsageStatus {
29 readonly isPending: boolean;29 readonly isPending: boolean;
30 readonly error: string | null;30 readonly error: string | null;
31 readonly summary: UsageSummary | null;31 readonly summary: UsageSummary | null;
32 readonly needsCursorKeychainAccess: boolean;
32}33}
3334
34/**35/**
...@@ -46,12 +47,17 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>...@@ -46,12 +47,17 @@ const usageByWindowAtom = Atom.family((windowKey: string) =>
46 const statuses: EnvironmentUsageStatus[] = [];47 const statuses: EnvironmentUsageStatus[] = [];
47 for (const [environmentId, presentation] of presentations) {48 for (const [environmentId, presentation] of presentations) {
48 const result = get(serverEnvironment.usageSummary({ environmentId, input }));49 const result = get(serverEnvironment.usageSummary({ environmentId, input }));
50 const summary = Option.getOrNull(AsyncResult.value(result));
49 statuses.push({51 statuses.push({
50 environmentId,52 environmentId,
51 label: presentation.entry.target.label,53 label: presentation.entry.target.label,
52 isPending: result.waiting,54 isPending: result.waiting,
53 error: result._tag === "Failure" ? "This environment could not report usage." : null,55 error: result._tag === "Failure" ? "This environment could not report usage." : null,
54 summary: Option.getOrNull(AsyncResult.value(result)),56 summary,
57 needsCursorKeychainAccess: needsCursorKeychainAccess(
58 summary,
59 get(serverEnvironment.providersValueAtom(environmentId)),
60 ),
55 });61 });
56 }62 }
57 return statuses;63 return statuses;
packages/client-runtime/src/state/usage.test.ts+49-1
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1import {1import {
2 EnvironmentId,2 EnvironmentId,
3 ProviderDriverKind,
4 ProviderInstanceId,
3 UsageDay,5 UsageDay,
4 USAGE_CONTRACT_VERSION,6 USAGE_CONTRACT_VERSION,
7 type ServerProvider,
5 type UsageSummary,8 type UsageSummary,
6} from "@t3tools/contracts";9} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
...@@ -10,7 +13,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test";...@@ -10,7 +13,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test";
1013
11import type { EnvironmentPresentation } from "../connection/presentation.ts";14import type { EnvironmentPresentation } from "../connection/presentation.ts";
12import { EnvironmentRpcUnavailableError } from "../rpc/client.ts";15import { EnvironmentRpcUnavailableError } from "../rpc/client.ts";
13import { refreshUsage, refreshUsageLimits } from "./usage.ts";16import { needsCursorKeychainAccess, refreshUsage, refreshUsageLimits } from "./usage.ts";
1417
15const input = {18const input = {
16 sinceDay: UsageDay.make("2026-09-05"),19 sinceDay: UsageDay.make("2026-09-05"),
...@@ -238,3 +241,48 @@ describe("limits refresh cooldown", () => {...@@ -238,3 +241,48 @@ describe("limits refresh cooldown", () => {
238 }241 }
239 });242 });
240});243});
244
245describe("needsCursorKeychainAccess", () => {
246 const cursorPrompt: UsageSummary = {
247 ...summary,
248 sources: [
249 {
250 fingerprint: {
251 hostId: "host",
252 provider: "cursor",
253 resolvedHomePath: "/Users/me/.cursor/auth.json",
254 volumeId: "volume",
255 },
256 status: "ok",
257 scannedFiles: 0,
258 skippedFiles: 0,
259 malformedRecords: 0,
260 distinctSessions: 0,
261 message: "Cursor account usage is off on this environment.",
262 action: "enableCursorKeychain",
263 },
264 ],
265 };
266 const cursor = (status: ServerProvider["status"]): ServerProvider => ({
267 instanceId: ProviderInstanceId.make("cursor"),
268 driver: ProviderDriverKind.make("cursor"),
269 enabled: status !== "disabled",
270 installed: status === "ready",
271 version: null,
272 status,
273 auth: { status: "unknown" },
274 checkedAt: "2026-09-05T12:00:00.000Z",
275 models: [],
276 slashCommands: [],
277 skills: [],
278 });
279
280 it("offers access only when Cursor is ready on that environment", () => {
281 expect(needsCursorKeychainAccess(cursorPrompt, [cursor("ready")])).toBe(true);
282 expect(needsCursorKeychainAccess(cursorPrompt, [cursor("error")])).toBe(false);
283 expect(needsCursorKeychainAccess(cursorPrompt, [cursor("disabled")])).toBe(false);
284 expect(needsCursorKeychainAccess(cursorPrompt, [])).toBe(false);
285 expect(needsCursorKeychainAccess(cursorPrompt, null)).toBe(false);
286 expect(needsCursorKeychainAccess(summary, [cursor("ready")])).toBe(false);
287 });
288});
packages/client-runtime/src/state/usage.ts+18-1
...@@ -1,4 +1,9 @@...@@ -1,4 +1,9 @@
1import type { EnvironmentId, UsageSummaryInput } from "@t3tools/contracts";1import type {
2 EnvironmentId,
3 ServerProvider,
4 UsageSummary,
5 UsageSummaryInput,
6} from "@t3tools/contracts";
2import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
3import type { AtomRegistry } from "effect/unstable/reactivity";8import type { AtomRegistry } from "effect/unstable/reactivity";
49
...@@ -9,6 +14,18 @@ import type { createServerEnvironmentAtoms } from "./server.ts";...@@ -9,6 +14,18 @@ import type { createServerEnvironmentAtoms } from "./server.ts";
914
10const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError);15const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError);
1116
17/** Offer the Cursor Keychain prompt only where a working Cursor provider could use it. */
18export function needsCursorKeychainAccess(
19 summary: UsageSummary | null,
20 providers: readonly ServerProvider[] | null,
21): boolean {
22 return (
23 summary?.sources.some((source) => source.action === "enableCursorKeychain") === true &&
24 providers?.some((provider) => provider.driver === "cursor" && provider.status === "ready") ===
25 true
26 );
27}
28
12const limitsRefreshAfter = new Map<EnvironmentId, number>();29const limitsRefreshAfter = new Map<EnvironmentId, number>();
13const limitsRefreshes = new Map<EnvironmentId, Promise<unknown>>();30const limitsRefreshes = new Map<EnvironmentId, Promise<unknown>>();
1431