| author | |
| committer | |
| log | 94c42162b443072ca59a0733c02ee54229a61fef |
| tree | 7543a044f0a4f8c9a318ced4d5cbc63154c58426 |
| parent | 999161ef844a7a38c8a6d08195c1240759169832 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 19 | import { refreshUsage } from "@t3tools/client-runtime/state/usage"; | 19 | import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; |
| 20 | import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; | 20 | import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; |
| 21 | import * as Option from "effect/Option"; | 21 | import * as Option from "effect/Option"; |
| 22 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 22 | import { 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 | } |
| 37 | 38 | ||
| 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 === null | 28 | cost === null |
| 28 | ? null | 29 | ? 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"; |
| 16 | import { refreshUsage } from "@t3tools/client-runtime/state/usage"; | 16 | import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; |
| 17 | import * as Option from "effect/Option"; | 17 | import * as Option from "effect/Option"; |
| 18 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 18 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 19 | import { useCallback, useMemo } from "react"; | 19 | import { 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 | } |
| 33 | 34 | ||
| 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 7 | import * as Effect from "effect/Effect"; | 10 | import * 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"; |
| 10 | 13 | ||
| 11 | import type { EnvironmentPresentation } from "../connection/presentation.ts"; | 14 | import type { EnvironmentPresentation } from "../connection/presentation.ts"; |
| 12 | import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; | 15 | import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; |
| 13 | import { refreshUsage, refreshUsageLimits } from "./usage.ts"; | 16 | import { needsCursorKeychainAccess, refreshUsage, refreshUsageLimits } from "./usage.ts"; |
| 14 | 17 | ||
| 15 | const input = { | 18 | const 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 | |||
| 245 | describe("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 @@ |
| 1 | import type { EnvironmentId, UsageSummaryInput } from "@t3tools/contracts"; | 1 | import type { |
| 2 | EnvironmentId, | ||
| 3 | ServerProvider, | ||
| 4 | UsageSummary, | ||
| 5 | UsageSummaryInput, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 2 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 3 | import type { AtomRegistry } from "effect/unstable/reactivity"; | 8 | import type { AtomRegistry } from "effect/unstable/reactivity"; |
| 4 | 9 | ||
| ... | @@ -9,6 +14,18 @@ import type { createServerEnvironmentAtoms } from "./server.ts"; | ... | @@ -9,6 +14,18 @@ import type { createServerEnvironmentAtoms } from "./server.ts"; |
| 9 | 14 | ||
| 10 | const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); | 15 | const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); |
| 11 | 16 | ||
| 17 | /** Offer the Cursor Keychain prompt only where a working Cursor provider could use it. */ | ||
| 18 | export 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 | |||
| 12 | const limitsRefreshAfter = new Map<EnvironmentId, number>(); | 29 | const limitsRefreshAfter = new Map<EnvironmentId, number>(); |
| 13 | const limitsRefreshes = new Map<EnvironmentId, Promise<unknown>>(); | 30 | const limitsRefreshes = new Map<EnvironmentId, Promise<unknown>>(); |
| 14 | 31 |