From 48883bddd8202654d5ba4d51deb88d63196a3107 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Mon, 10 Aug 2026 23:01:01 -0700 Subject: [PATCH] fix(usage): offer browser login when local auth lacks usage meters - Add needsBrowserSessionForUsage to usage provider descriptors (OpenCode) - Keep sign-in visible for empty-window ok snapshots without a stored session - Cover helper and OpenCode login/sign-out flows in unit tests --- packages/agents-usage/src/providers.ts | 1 + packages/agents-usage/src/types.ts | 2 + .../providers/usageProviders.test.ts | 6 +++ .../components/providers/usageProviders.ts | 5 ++ .../providers/useUsageProviderLogin.test.ts | 52 +++++++++++++++++++ .../providers/useUsageProviderLogin.ts | 19 ++++++- 6 files changed, 83 insertions(+), 2 deletions(-) diff --git a/packages/agents-usage/src/providers.ts b/packages/agents-usage/src/providers.ts index be09cd5c5..4aab59181 100644 --- a/packages/agents-usage/src/providers.ts +++ b/packages/agents-usage/src/providers.ts @@ -116,6 +116,7 @@ export const LOCAL_USAGE_PROVIDER_DESCRIPTORS: readonly UsageProviderDescriptor[ // Cookie login for live Go/Zen meters; local auth.json only gates the plan badge. mechanism: "cookie", needsLogin: true, + needsBrowserSessionForUsage: true, windowIds: ["session-5h", "weekly", "monthly"], }, ]; diff --git a/packages/agents-usage/src/types.ts b/packages/agents-usage/src/types.ts index 8692b5a02..b87ba5d5d 100644 --- a/packages/agents-usage/src/types.ts +++ b/packages/agents-usage/src/types.ts @@ -157,6 +157,8 @@ export interface UsageProviderDescriptor { mechanism: UsageMechanism; /** True when a token/cookie may need to be captured via a login flow. */ needsLogin: boolean; + /** True when a local credential can exist but usage meters still need a browser session. */ + needsBrowserSessionForUsage?: boolean; /** A cookie-backed provider also accepts a pasted API key. */ apiKeyFallback?: boolean; windowIds: UsageWindowId[]; diff --git a/src/renderer/components/providers/usageProviders.test.ts b/src/renderer/components/providers/usageProviders.test.ts index ed891d259..4e5d2309f 100644 --- a/src/renderer/components/providers/usageProviders.test.ts +++ b/src/renderer/components/providers/usageProviders.test.ts @@ -4,6 +4,7 @@ import type { AgentInstanceConfigMap } from "@/shared/contracts"; import { hasRailUsage, isClaudeUsageProvider, + needsBrowserSessionForUsage, pickUsageRings, resolveDisplayedProviders, supportsApiKeyLogin, @@ -49,6 +50,11 @@ describe("usageProviders", () => { expect(supportsBrowserLogin("qwen")).toBe(true); }); + it("identifies providers whose empty local snapshot still needs browser usage auth", () => { + expect(needsBrowserSessionForUsage("opencode")).toBe(true); + expect(needsBrowserSessionForUsage("grok")).toBe(false); + }); + it("adds Claude profile providers after the base Claude provider", () => { const providers = usageProvidersForAgentInstances(agentInstances); const claudeIndex = providers.findIndex((provider) => provider.id === "claude"); diff --git a/src/renderer/components/providers/usageProviders.ts b/src/renderer/components/providers/usageProviders.ts index 82a1c4f03..80155a203 100644 --- a/src/renderer/components/providers/usageProviders.ts +++ b/src/renderer/components/providers/usageProviders.ts @@ -190,6 +190,11 @@ export function supportsApiKeyLogin(providerId: string): boolean { ); } +/** True when the provider's plan badge can be known locally but its meters need browser auth. */ +export function needsBrowserSessionForUsage(providerId: string): boolean { + return USAGE_PROVIDER_BY_ID.get(baseAgentKind(providerId))?.needsBrowserSessionForUsage === true; +} + /** Providers whose windows share one reset clock (one header countdown, no per-window resets). */ export function usesSharedWindowReset(providerId: string): boolean { return rendererMeta(providerId)?.sharedWindowReset === true; diff --git a/src/renderer/components/providers/useUsageProviderLogin.test.ts b/src/renderer/components/providers/useUsageProviderLogin.test.ts index 56dd15ad5..5cfc4a2be 100644 --- a/src/renderer/components/providers/useUsageProviderLogin.test.ts +++ b/src/renderer/components/providers/useUsageProviderLogin.test.ts @@ -45,6 +45,16 @@ function okSnapshot(providerId: string): UsageSnapshot { } as UsageSnapshot; } +function localGoSnapshot(): UsageSnapshot { + return { + providerId: "opencode", + status: "ok", + plan: "Go", + windows: [], + fetchedAt: 2, + } as UsageSnapshot; +} + describe("useUsageProviderLogin", () => { beforeEach(() => { bridgeMock.isRemoteSession.mockReturnValue(false); @@ -67,6 +77,48 @@ describe("useUsageProviderLogin", () => { expect(result.current.canSignIn).toBe(true); }); + it("offers OpenCode browser login when local Go auth has no web meters", () => { + useProviderUsageStore.getState().mergeSnapshot(localGoSnapshot()); + + const { result } = renderHook(() => useUsageProviderLogin("opencode")); + + expect(result.current.canBrowserSignIn).toBe(true); + expect(result.current.canSignOut).toBe(false); + }); + + it("keeps OpenCode sign-out available after a browser session is stored", () => { + useProviderUsageStore.getState().mergeSnapshot(localGoSnapshot()); + useUsageLoginStateStore.getState().setStored("opencode", true); + + const { result } = renderHook(() => useUsageProviderLogin("opencode")); + + expect(result.current.canBrowserSignIn).toBe(false); + expect(result.current.canSignOut).toBe(true); + }); + + it("clears the stored OpenCode session and refreshes after sign-out", async () => { + bridgeMock.clearUsageLogin.mockResolvedValue({ ok: true }); + bridgeMock.refreshProviderUsage.mockResolvedValue({ + snapshots: [authMissingSnapshot("opencode")], + fromCache: false, + }); + useProviderUsageStore.getState().mergeSnapshot(localGoSnapshot()); + useUsageLoginStateStore.getState().setStored("opencode", true); + + const { result } = renderHook(() => useUsageProviderLogin("opencode")); + + await act(async () => { + await result.current.handleSignOut(); + }); + + expect(bridgeMock.clearUsageLogin).toHaveBeenCalledWith({ providerId: "opencode" }); + expect(useUsageLoginStateStore.getState().stored.opencode).toBe(false); + expect(bridgeMock.refreshProviderUsage).toHaveBeenCalledWith({ + providerIds: ["opencode"], + force: true, + }); + }); + it("offers browser-session and API-key paths for Alibaba Token Plan", () => { useProviderUsageStore.getState().mergeSnapshot(authMissingSnapshot("qwen")); diff --git a/src/renderer/components/providers/useUsageProviderLogin.ts b/src/renderer/components/providers/useUsageProviderLogin.ts index 90658f47e..c855dd9de 100644 --- a/src/renderer/components/providers/useUsageProviderLogin.ts +++ b/src/renderer/components/providers/useUsageProviderLogin.ts @@ -7,7 +7,11 @@ import { useUsageLoginStateStore, } from "@/renderer/state/usageLoginStateStore"; import { refreshAndMergeProviderUsage } from "./refreshProviderUsageSnapshot"; -import { supportsApiKeyLogin, supportsBrowserLogin } from "./usageProviders"; +import { + needsBrowserSessionForUsage, + supportsApiKeyLogin, + supportsBrowserLogin, +} from "./usageProviders"; /** * Sign-in / sign-out flow for a usage provider, shared by the usage panel card @@ -32,8 +36,19 @@ export function useUsageProviderLogin(id: string) { // by another path (e.g. Copilot's OAuth/CLI token) has no stored cookie session // yet is signed in — offering "Sign in" there is wrong. const sessionRejected = snapshot?.status === "auth-missing"; + // OpenCode can report a local Go plan before its browser session is captured, + // but the usage meters are only available through that web session. Keep the + // sign-in action visible for the empty-meter state, including a cached snapshot + // from before the browser session was captured. + const needsUsageSession = + !hasStoredSession && + needsBrowserSessionForUsage(id) && + snapshot?.status === "ok" && + snapshot.windows.length === 0; const canSignIn = - supportsLogin && snapshot?.status !== "ok" && (!hasStoredSession || sessionRejected); + supportsLogin && + (snapshot?.status !== "ok" || needsUsageSession) && + (!hasStoredSession || sessionRejected); const canBrowserSignIn = canSignIn && isBrowserLogin; const canApiKeySignIn = canSignIn && isApiKeyLogin; const canSignOut = supportsLogin && hasStoredSession;