diff --git a/src/renderer/src/components/status-bar/owner-usage-rate-limits.test.ts b/src/renderer/src/components/status-bar/owner-usage-rate-limits.test.ts new file mode 100644 index 00000000000..d0f973ef7e3 --- /dev/null +++ b/src/renderer/src/components/status-bar/owner-usage-rate-limits.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import type { ProviderRateLimits, RateLimitState } from '../../../../shared/rate-limit-types' +import { createEmptyRateLimitState } from '../../../../shared/rate-limit-state-factory' +import { resolveOwnerUsageRateLimits } from './owner-usage-rate-limits' +import { getProviderUsageErrorMessage, getProviderUsageStatusLabel } from './usage-error-copy' + +function provider(id: ProviderRateLimits['provider'], usedPercent: number): ProviderRateLimits { + return { + provider: id, + session: { usedPercent, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + updatedAt: 1, + error: null, + status: 'ok' + } +} + +const local = createEmptyRateLimitState({ + claude: provider('claude', 11), + codex: provider('codex', 22), + grok: provider('grok', 33), + grokAuthConfigured: true +}) + +describe('resolveOwnerUsageRateLimits', () => { + it('passes local usage through when this device owns the accounts', () => { + expect(resolveOwnerUsageRateLimits(local, { kind: 'local' })).toBe(local) + }) + + it("shows the remote owner's Claude and Codex usage, not the viewer's", () => { + const remote = createEmptyRateLimitState({ + claude: provider('claude', 70), + codex: provider('codex', 80) + }) + const resolved = resolveOwnerUsageRateLimits(local, { kind: 'remote', rateLimits: remote }) + expect(resolved.claude?.session?.usedPercent).toBe(70) + expect(resolved.codex?.session?.usedPercent).toBe(80) + // The viewer's Grok login is not the host's. + expect(resolved.grok).toBeNull() + expect(resolved.grokAuthConfigured).toBe(false) + }) + + it('treats providers and flags an older host omits as not configured there', () => { + const { + cursor: _cursor, + zcode: _zcode, + cursorAuthConfigured: _flag, + ...older + } = createEmptyRateLimitState({ claude: provider('claude', 5) }) + // Why: an older host's payload lacks keys the current type requires. + const rateLimits: RateLimitState = Object.assign(Object.create(null), older) + const resolved = resolveOwnerUsageRateLimits(local, { kind: 'remote', rateLimits }) + expect(resolved.cursor).toBeNull() + expect(resolved.zcode).toBeNull() + expect(resolved.cursorAuthConfigured).toBe(false) + expect(resolved.claude?.session?.usedPercent).toBe(5) + }) + + it('never renders local percentages while the first remote snapshot is in flight', () => { + const resolved = resolveOwnerUsageRateLimits(local, { kind: 'remote-pending' }) + for (const key of ['claude', 'codex', 'grok'] as const) { + expect(resolved[key]?.status).toBe('fetching') + expect(resolved[key]?.session).toBeNull() + } + }) + + it('says usage is unavailable when an older host publishes none, without erroring', () => { + const resolved = resolveOwnerUsageRateLimits(local, { + kind: 'remote-unverifiable', + ownerLabel: 'Mac Mini', + reason: 'usage-not-published', + lastKnown: null + }) + const claude = resolved.claude! + expect(claude.session).toBeNull() + expect(getProviderUsageStatusLabel(claude)).toBe('Usage unavailable') + expect(getProviderUsageErrorMessage(claude)).toContain('Mac Mini does not report usage') + }) + + it('keeps the bars an unreachable host vouched for, blanked, and invents none', () => { + const lastKnown = createEmptyRateLimitState({ codex: provider('codex', 40) }) + const resolved = resolveOwnerUsageRateLimits(local, { + kind: 'remote-unverifiable', + ownerLabel: 'devbox', + reason: 'unreachable', + lastKnown + }) + expect(resolved.codex?.session).toBeNull() + expect(getProviderUsageStatusLabel(resolved.codex!)).toBe('Usage unavailable') + expect(getProviderUsageErrorMessage(resolved.codex!)).toContain('cannot reach devbox') + expect(resolved.claude).toBeNull() + expect(resolved.grok).toBeNull() + }) + + it("never copies the viewer's setup flags into a pending or disconnected remote state", () => { + const pending = resolveOwnerUsageRateLimits(local, { kind: 'remote-pending' }) + expect(pending.grokAuthConfigured).toBe(false) + const lastKnown = createEmptyRateLimitState({ + codex: provider('codex', 40), + cursorAuthConfigured: true + }) + const lost = resolveOwnerUsageRateLimits(local, { + kind: 'remote-unverifiable', + ownerLabel: 'devbox', + reason: 'unreachable', + lastKnown + }) + expect(lost.grokAuthConfigured).toBe(false) + expect(lost.cursorAuthConfigured).toBe(true) + const neverAnswered = resolveOwnerUsageRateLimits(local, { + kind: 'remote-unverifiable', + ownerLabel: 'devbox', + reason: 'unreachable', + lastKnown: null + }) + expect(neverAnswered.grokAuthConfigured).toBe(false) + expect(neverAnswered.grok).toBeNull() + expect(neverAnswered.codex?.status).toBe('error') + }) + + it('stops a slot the host was still fetching from loading through an outage', () => { + const lastKnown = createEmptyRateLimitState({ + grok: { ...provider('grok', 0), session: null, status: 'fetching' }, + grokAuthConfigured: true + }) + const lost = resolveOwnerUsageRateLimits(local, { + kind: 'remote-unverifiable', + ownerLabel: 'devbox', + reason: 'unreachable', + lastKnown + }) + expect(lost.grok?.status).toBe('error') + }) +}) diff --git a/src/renderer/src/components/status-bar/owner-usage-rate-limits.ts b/src/renderer/src/components/status-bar/owner-usage-rate-limits.ts new file mode 100644 index 00000000000..82bea214b69 --- /dev/null +++ b/src/renderer/src/components/status-bar/owner-usage-rate-limits.ts @@ -0,0 +1,142 @@ +import type { ProviderRateLimits, RateLimitState } from '../../../../shared/rate-limit-types' +import { createEmptyRateLimitState } from '../../../../shared/rate-limit-state-factory' +import { translate } from '@/i18n/i18n' +import { + createPendingProviderSnapshot, + hasUsageData, + isProviderConfigured +} from './status-bar-provider-visibility' + +/** Whose usage the status bar shows: this device, or the remote host that owns the accounts. */ +export type OwnerUsageState = + | { kind: 'local' } + /** A remote host owns usage and has not answered yet. */ + | { kind: 'remote-pending' } + | { kind: 'remote'; rateLimits: RateLimitState } + /** The owning host's usage cannot be verified; no bar may claim numbers. */ + | { + kind: 'remote-unverifiable' + ownerLabel: string + reason: OwnerUsageFailureReason + /** Last snapshot that host vouched for, so its bars keep their slots. */ + lastKnown: RateLimitState | null + } + +/** A host that answers without usage is reachable, so "cannot reach" would be false. */ +export type OwnerUsageFailureReason = 'unreachable' | 'usage-not-published' + +type UsageProviderKey = { + [K in keyof RateLimitState]-?: RateLimitState[K] extends ProviderRateLimits | null ? K : never +}[keyof RateLimitState] + +// Why a Record: a new provider is a compile error here, not a silent leak of local numbers. +const USAGE_PROVIDER_IDS: Record = { + claude: 'claude', + codex: 'codex', + gemini: 'gemini', + opencodeGo: 'opencode-go', + kimi: 'kimi', + antigravity: 'antigravity', + minimax: 'minimax', + grok: 'grok', + cursor: 'cursor', + zcode: 'zcode' +} + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the keys of a Record. +const USAGE_PROVIDER_KEYS = Object.keys(USAGE_PROVIDER_IDS) as UsageProviderKey[] + +function mapProviders( + build: (key: UsageProviderKey) => ProviderRateLimits | null +): Pick { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: one entry per UsageProviderKey. + return Object.fromEntries(USAGE_PROVIDER_KEYS.map((key) => [key, build(key)])) as Pick< + RateLimitState, + UsageProviderKey + > +} + +function describeUnverifiableOwner(ownerLabel: string, reason: OwnerUsageFailureReason): string { + return reason === 'usage-not-published' + ? translate( + 'auto.components.status.bar.tooltip.remoteOwnerNoUsage', + 'Usage unavailable — {{server}} does not report usage. Update Orca on that host.', + { server: ownerLabel } + ) + : translate( + 'auto.components.status.bar.tooltip.remoteOwnerUnreachable', + 'Usage unavailable — cannot reach {{server}}', + { server: ownerLabel } + ) +} + +function adoptRemoteRateLimits(remote: RateLimitState): RateLimitState { + // Why: an older host omits newer providers and flags; read through a Partial so those + // become "not configured" on that host rather than undefined or the viewer's own values. + const partial: Partial = remote + return { + ...remote, + ...mapProviders((key) => partial[key] ?? null), + minimaxCookieConfigured: partial.minimaxCookieConfigured === true, + minimaxApiKeyConfigured: partial.minimaxApiKeyConfigured === true, + opencodeGoApiKeyConfigured: partial.opencodeGoApiKeyConfigured === true, + grokAuthConfigured: partial.grokAuthConfigured === true, + cursorAuthConfigured: partial.cursorAuthConfigured === true, + zcodePlanApiKeyConfigured: partial.zcodePlanApiKeyConfigured === true, + claudeTarget: partial.claudeTarget ?? { runtime: 'host', wslDistro: null }, + codexTarget: partial.codexTarget ?? { runtime: 'host', wslDistro: null }, + inactiveClaudeAccounts: partial.inactiveClaudeAccounts ?? [], + inactiveCodexAccounts: partial.inactiveCodexAccounts ?? [] + } +} + +// Why: an older host publishes no usage at all; its account services always cover these. +const ACCOUNT_OWNED_PROVIDERS: ReadonlySet = new Set(['claude', 'codex']) + +export function resolveOwnerUsageRateLimits( + local: RateLimitState, + owner: OwnerUsageState +): RateLimitState { + if (owner.kind === 'local') { + return local + } + if (owner.kind === 'remote') { + return adoptRemoteRateLimits(owner.rateLimits) + } + // Why: nothing of the viewer's (numbers or setup flags) may stand in for the remote host. + if (owner.kind === 'remote-pending') { + return createEmptyRateLimitState( + mapProviders((key) => createPendingProviderSnapshot(USAGE_PROVIDER_IDS[key])) + ) + } + const message = describeUnverifiableOwner(owner.ownerLabel, owner.reason) + const base = owner.lastKnown ? adoptRemoteRateLimits(owner.lastKnown) : null + const stamp = (key: UsageProviderKey): ProviderRateLimits => ({ + ...createPendingProviderSnapshot(USAGE_PROVIDER_IDS[key]), + error: message, + usageMetadata: { unverifiableOwner: true }, + status: 'error' + }) + if (!base) { + return createEmptyRateLimitState( + mapProviders((key) => (ACCOUNT_OWNED_PROVIDERS.has(key) ? stamp(key) : null)) + ) + } + return { + ...base, + ...mapProviders((key) => { + const provider = base[key] + // Why: stamping a slot the host reported unconfigured would invent a bar it never had; + // a still-fetching slot was real and must not keep loading through the outage. + if ( + !provider || + (provider.status !== 'fetching' && + !isProviderConfigured(provider) && + !hasUsageData(provider)) + ) { + return provider + } + return stamp(key) + }) + } +} diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index 4ade7294abf..20d41eabbee 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -41,7 +41,7 @@ type UsageProviderSnapshots = { type UsageProviderId = ProviderRateLimits['provider'] -function hasUsageData(provider: ProviderRateLimits): boolean { +export function hasUsageData(provider: ProviderRateLimits): boolean { return Boolean( provider.session || provider.weekly || @@ -135,7 +135,7 @@ export function hasUsageProviderSettingsForProvider( return false } -function createPendingProviderSnapshot(providerId: UsageProviderId): ProviderRateLimits { +export function createPendingProviderSnapshot(providerId: UsageProviderId): ProviderRateLimits { return { provider: providerId, session: null, diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index 99233f52d53..daddf7e1316 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -98,6 +98,10 @@ export function isClaudeUsageWaitingForClaude(p: ProviderRateLimits): boolean { } export function getProviderUsageStatusLabel(p: ProviderRateLimits): string { + // Why: no refresh fixes an owner that cannot vouch for its usage, so never say "Refresh failed". + if (p.usageMetadata?.unverifiableOwner) { + return translate('auto.components.status.bar.tooltip.f8b8dbed85', 'Usage unavailable') + } const delegatedCliProvider = getDelegatedCliRefreshProvider(p) if (delegatedCliProvider === 'grok') { return translate('auto.components.status.bar.tooltip.e2c6a4f917', 'Run Grok to refresh') @@ -178,6 +182,9 @@ export function getProviderUsageErrorMessage(p: ProviderRateLimits): string { if (!p.error) { return fallback } + if (p.usageMetadata?.unverifiableOwner) { + return p.error + } const delegatedCliProvider = getDelegatedCliRefreshProvider(p) if (delegatedCliProvider === 'grok') { return translate( diff --git a/src/renderer/src/components/status-bar/use-owner-usage-rate-limits.test.tsx b/src/renderer/src/components/status-bar/use-owner-usage-rate-limits.test.tsx new file mode 100644 index 00000000000..3ffc94466c6 --- /dev/null +++ b/src/renderer/src/components/status-bar/use-owner-usage-rate-limits.test.tsx @@ -0,0 +1,222 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' +import { createEmptyRateLimitState } from '../../../../shared/rate-limit-state-factory' +import type { ProviderAccountsSnapshot } from '@/runtime/runtime-provider-accounts-client' + +type Handlers = { + onSnapshot: (snapshot: ProviderAccountsSnapshot) => void + onError: (error: unknown) => void + onClosedAfterSnapshot?: () => void +} + +type Watcher = { target: RuntimeClientTarget; handlers: Handlers; closed: boolean } + +const mocks = vi.hoisted(() => { + const state: { owner: RuntimeClientTarget; watchers: Watcher[]; callRuntimeRpc: Mock } = { + owner: { kind: 'local' }, + watchers: [], + callRuntimeRpc: vi.fn() + } + return state +}) + +vi.mock('@/runtime/use-worktree-runtime-target', () => ({ + useActiveAccountOwner: () => ({ target: mocks.owner, wslHostDistro: null }) +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) +vi.mock('@/runtime/runtime-provider-accounts-client', () => ({ + watchProviderAccounts: (target: RuntimeClientTarget, handlers: Handlers) => { + const watcher = { target, handlers, closed: false } + mocks.watchers.push(watcher) + return { + close: () => { + watcher.closed = true + } + } + } +})) +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ settings: null, runtimeEnvironments: [{ id: 'mini', name: 'Mac Mini' }] }) +})) + +const { useOwnerUsageRateLimits } = await import('./use-owner-usage-rate-limits') + +function provider(id: ProviderRateLimits['provider'], usedPercent: number): ProviderRateLimits { + return { + provider: id, + session: { usedPercent, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + updatedAt: 1, + error: null, + status: 'ok' + } +} + +function snapshot(rateLimits: ProviderAccountsSnapshot['rateLimits']): ProviderAccountsSnapshot { + const empty = { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } + return { claude: empty, codex: empty, rateLimits } +} + +const local = createEmptyRateLimitState({ claude: provider('claude', 11) }) + +describe('useOwnerUsageRateLimits', () => { + beforeEach(() => { + mocks.owner = { kind: 'local' } + mocks.watchers.length = 0 + mocks.callRuntimeRpc.mockReset() + }) + afterEach(() => cleanup()) + + it('opens no watcher and keeps local usage when this device owns accounts', () => { + const { result } = renderHook(() => useOwnerUsageRateLimits(local)) + expect(mocks.watchers).toHaveLength(0) + expect(result.current.rateLimits).toBe(local) + expect(result.current.refreshRemote).toBeNull() + }) + + it("reads usage from the remote owner's accounts stream", () => { + mocks.owner = { kind: 'environment', environmentId: 'mini' } + const { result } = renderHook(() => useOwnerUsageRateLimits(local)) + expect(mocks.watchers[0].target).toEqual({ kind: 'environment', environmentId: 'mini' }) + expect(result.current.remoteOwned).toBe(true) + expect(result.current.rateLimits.claude?.session).toBeNull() + + act(() => + mocks.watchers[0].handlers.onSnapshot( + snapshot(createEmptyRateLimitState({ claude: provider('claude', 64) })) + ) + ) + expect(result.current.rateLimits.claude?.session?.usedPercent).toBe(64) + }) + + it('reports an older host without published usage as unavailable, not loading', () => { + mocks.owner = { kind: 'environment', environmentId: 'mini' } + const { result } = renderHook(() => useOwnerUsageRateLimits(local)) + act(() => mocks.watchers[0].handlers.onSnapshot(snapshot(null))) + expect(result.current.rateLimits.claude?.status).toBe('error') + expect(result.current.rateLimits.claude?.usageMetadata?.unverifiableOwner).toBe(true) + expect(result.current.rateLimits.claude?.error).toContain('Mac Mini does not report usage') + }) + + it('turns loss of contact into an unverifiable verdict that keeps known bars', () => { + mocks.owner = { kind: 'environment', environmentId: 'mini' } + const { result } = renderHook(() => useOwnerUsageRateLimits(local)) + act(() => + mocks.watchers[0].handlers.onSnapshot( + snapshot(createEmptyRateLimitState({ codex: provider('codex', 30) })) + ) + ) + act(() => mocks.watchers[0].handlers.onError(new Error('closed'))) + expect(result.current.rateLimits.codex?.session).toBeNull() + expect(result.current.rateLimits.codex?.error).toContain('cannot reach Mac Mini') + }) + + it('never shows server A numbers under server B', () => { + mocks.owner = { kind: 'environment', environmentId: 'mini' } + const { result, rerender } = renderHook(() => useOwnerUsageRateLimits(local)) + const first = mocks.watchers[0] + act(() => + first.handlers.onSnapshot( + snapshot(createEmptyRateLimitState({ claude: provider('claude', 90) })) + ) + ) + mocks.owner = { kind: 'environment', environmentId: 'devbox' } + rerender() + expect(first.closed).toBe(true) + expect(result.current.rateLimits.claude?.session).toBeNull() + act(() => + first.handlers.onSnapshot( + snapshot(createEmptyRateLimitState({ claude: provider('claude', 91) })) + ) + ) + expect(result.current.rateLimits.claude?.session).toBeNull() + }) + + it('forces the owning host to refresh usage', async () => { + mocks.owner = { kind: 'environment', environmentId: 'mini' } + mocks.callRuntimeRpc.mockResolvedValue( + snapshot(createEmptyRateLimitState({ claude: provider('claude', 12) })) + ) + const { result } = renderHook(() => useOwnerUsageRateLimits(local)) + await act(async () => { + await result.current.refreshRemote?.() + }) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'mini' }, + 'accounts.list', + { refreshUsage: true }, + expect.anything() + ) + await waitFor(() => expect(result.current.rateLimits.claude?.session?.usedPercent).toBe(12)) + }) + + it('marks usage unverifiable when the stream closes after its first snapshot, then reconnects', () => { + vi.useFakeTimers() + try { + mocks.owner = { kind: 'environment', environmentId: 'mini' } + const { result } = renderHook(() => useOwnerUsageRateLimits(local)) + act(() => + mocks.watchers[0].handlers.onSnapshot( + snapshot(createEmptyRateLimitState({ codex: provider('codex', 30) })) + ) + ) + act(() => mocks.watchers[0].handlers.onClosedAfterSnapshot?.()) + expect(result.current.rateLimits.codex?.session).toBeNull() + expect(result.current.rateLimits.codex?.error).toContain('cannot reach Mac Mini') + act(() => { + vi.advanceTimersByTime(5_000) + }) + expect(mocks.watchers[0].closed).toBe(true) + expect(mocks.watchers).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + + it('reopens the stream periodically so a host that does not poll still refreshes', () => { + vi.useFakeTimers() + try { + mocks.owner = { kind: 'environment', environmentId: 'mini' } + renderHook(() => useOwnerUsageRateLimits(local)) + act(() => { + vi.advanceTimersByTime(5 * 60_000) + }) + expect(mocks.watchers).toHaveLength(2) + expect(mocks.watchers[0].closed).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('ignores a refresh that completes after the owner changed', async () => { + mocks.owner = { kind: 'environment', environmentId: 'mini' } + let resolveRefresh: (value: ProviderAccountsSnapshot) => void = () => {} + mocks.callRuntimeRpc.mockReturnValue( + new Promise((resolve) => { + resolveRefresh = resolve + }) + ) + const { result, rerender } = renderHook(() => useOwnerUsageRateLimits(local)) + const pending = result.current.refreshRemote?.() + mocks.owner = { kind: 'environment', environmentId: 'devbox' } + rerender() + act(() => + mocks.watchers[1].handlers.onSnapshot( + snapshot(createEmptyRateLimitState({ claude: provider('claude', 20) })) + ) + ) + await act(async () => { + resolveRefresh(snapshot(createEmptyRateLimitState({ claude: provider('claude', 99) }))) + await pending + }) + expect(result.current.rateLimits.claude?.session?.usedPercent).toBe(20) + }) +}) diff --git a/src/renderer/src/components/status-bar/use-owner-usage-rate-limits.ts b/src/renderer/src/components/status-bar/use-owner-usage-rate-limits.ts new file mode 100644 index 00000000000..1026fba5acf --- /dev/null +++ b/src/renderer/src/components/status-bar/use-owner-usage-rate-limits.ts @@ -0,0 +1,180 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { translate } from '@/i18n/i18n' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' +import { + watchProviderAccounts, + type ProviderAccountsSnapshot +} from '@/runtime/runtime-provider-accounts-client' +import { useActiveAccountOwner } from '@/runtime/use-worktree-runtime-target' +import type { RateLimitState } from '../../../../shared/rate-limit-types' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { useAppStore } from '../../store' +import { + resolveOwnerUsageRateLimits, + type OwnerUsageFailureReason, + type OwnerUsageState +} from './owner-usage-rate-limits' + +// Why: accounts.list({refreshUsage}) waits for the host's provider fetches. +const REMOTE_USAGE_REFRESH_TIMEOUT_MS = 30_000 +// Matches the desktop's own usage poll cadence. +const REMOTE_USAGE_STALE_REOPEN_MS = 5 * 60_000 +const REMOTE_USAGE_RECONNECT_DELAY_MS = 5_000 +const LOCAL_TARGET: RuntimeClientTarget = { kind: 'local' } + +/** A snapshot only counts under the owner it came from, so A's numbers never render as B's. */ +type OwnedUsage = { + ownerId: string + rateLimits: RateLimitState | null + failure: OwnerUsageFailureReason | null +} + +export type OwnerUsageRateLimits = { + rateLimits: RateLimitState + /** True when a remote host (SSH-managed server or paired Orca) owns the shown usage. */ + remoteOwned: boolean + /** Forces the owning host to re-read usage; null when this device owns it. */ + refreshRemote: (() => Promise) | null +} + +/** + * Usage of whichever host owns the focused workspace's provider accounts. A remote owner's + * usage comes from its own accounts snapshot, so it is read on the execution host. + */ +export function useOwnerUsageRateLimits(local: RateLimitState): OwnerUsageRateLimits { + // Why: route by the focused workspace's owner, never by the Active Server setting. + const { target } = useActiveAccountOwner(LOCAL_TARGET) + const ownerId = runtimeTargetEnvironmentId(target) + const ownerName = useAppStore( + (s) => s.runtimeEnvironments.find((environment) => environment.id === ownerId)?.name ?? null + ) + const [owned, setOwned] = useState(null) + const ownedRef = useRef(null) + + const apply = useCallback((next: OwnedUsage) => { + ownedRef.current = next + setOwned(next) + }, []) + + // Why: losing contact must not erase what the host already vouched for. + const fail = useCallback( + (owner: string, failure: OwnerUsageFailureReason) => { + const current = ownedRef.current + apply({ + ownerId: owner, + rateLimits: current?.ownerId === owner ? current.rateLimits : null, + failure + }) + }, + [apply] + ) + + const applySnapshot = useCallback( + (owner: string, snapshot: ProviderAccountsSnapshot) => { + if (snapshot.rateLimits) { + apply({ ownerId: owner, rateLimits: snapshot.rateLimits, failure: null }) + } else { + // Why: a host that predates published usage answers without it; say so, never spin. + fail(owner, 'usage-not-published') + } + }, + [apply, fail] + ) + + const ownerIdRef = useRef(ownerId) + useEffect(() => { + ownerIdRef.current = ownerId + }, [ownerId]) + // Why: bumping reopens the stream; each open makes the host refresh stale usage. + const [streamGeneration, setStreamGeneration] = useState(0) + + useEffect(() => { + if (!ownerId) { + return + } + let active = true + const reopen = (): void => setStreamGeneration((value) => value + 1) + // Why: orcad does not poll on its own, so a long-lived stream would freeze the meters. + const staleTimer = setTimeout(reopen, REMOTE_USAGE_STALE_REOPEN_MS) + let reconnectTimer: ReturnType | undefined + const lost = (): void => { + if (active) { + fail(ownerId, 'unreachable') + clearTimeout(reconnectTimer) + reconnectTimer = setTimeout(reopen, REMOTE_USAGE_RECONNECT_DELAY_MS) + } + } + const watcher = watchProviderAccounts( + { kind: 'environment', environmentId: ownerId }, + { + onSnapshot: (snapshot) => { + if (active) { + applySnapshot(ownerId, snapshot) + } + }, + // Why: timeout, closed stream and RPC failure all mean the owner's usage is unverifiable. + onError: lost, + onClosedAfterSnapshot: lost + } + ) + return () => { + active = false + clearTimeout(staleTimer) + clearTimeout(reconnectTimer) + watcher.close() + } + }, [ownerId, streamGeneration, applySnapshot, fail]) + + const refreshRemote = useCallback(async () => { + if (!ownerId) { + return + } + try { + const snapshot = await callRuntimeRpc( + { kind: 'environment', environmentId: ownerId }, + 'accounts.list', + { refreshUsage: true }, + { timeoutMs: REMOTE_USAGE_REFRESH_TIMEOUT_MS } + ) + // Why: a refresh that outlives an owner switch must not overwrite the new owner's usage. + if (ownerIdRef.current === ownerId) { + applySnapshot(ownerId, snapshot) + } + } catch { + // Why: a slow forced refresh is not loss of contact; the stream owns reachability. + } + }, [ownerId, applySnapshot]) + + const ownerState = useMemo(() => { + if (!ownerId) { + return { kind: 'local' } + } + if (!owned || owned.ownerId !== ownerId) { + return { kind: 'remote-pending' } + } + if (owned.failure) { + return { + kind: 'remote-unverifiable', + ownerLabel: + ownerName?.trim() || + translate('auto.components.status.bar.StatusBar.remoteServerLabel', 'Remote server'), + reason: owned.failure, + lastKnown: owned.rateLimits + } + } + return owned.rateLimits + ? { kind: 'remote', rateLimits: owned.rateLimits } + : { kind: 'remote-pending' } + }, [ownerId, owned, ownerName]) + + const rateLimits = useMemo( + () => resolveOwnerUsageRateLimits(local, ownerState), + [local, ownerState] + ) + return { + rateLimits, + remoteOwned: ownerId !== null, + refreshRemote: ownerId ? refreshRemote : null + } +} diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts index 7ed84300dc9..2a2930bcae8 100644 --- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts +++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts @@ -14,10 +14,13 @@ import { import { useStatusBarMenuFocusHandoff } from './ProviderDetailsMenu' import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' import { useStatusBarDensity } from './status-bar-density' +import { useOwnerUsageRateLimits } from './use-owner-usage-rate-limits' export function useStatusBarController(floatingTerminalOpen: boolean) { const floatingTerminalShortcut = useShortcutLabel('floatingTerminal.toggle') - const rateLimits = useAppStore((s) => s.rateLimits) + const localRateLimits = useAppStore((s) => s.rateLimits) + // Why: with a remote account owner the agents run there, so its usage is the one to show. + const { rateLimits, remoteOwned, refreshRemote } = useOwnerUsageRateLimits(localRateLimits) const settings = useAppStore((s) => s.settings) const refreshRateLimits = useAppStore((s) => s.refreshRateLimits) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) @@ -38,7 +41,9 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { const floatingTerminalTriggerLocation = settings?.floatingTerminalTriggerLocation ?? 'floating-button' // Why: gate per-CLI bars on PATH detection so an uninstalled agent isn't shown a noisy empty bar (auto re-shows when installed). - const detectedAgentIds = useAppStore((s) => s.detectedAgentIds) + const localDetectedAgentIds = useAppStore((s) => s.detectedAgentIds) + // Why: PATH detection describes this viewer, not the remote host whose bars are shown. + const detectedAgentIds = remoteOwned ? null : localDetectedAgentIds const ensureDetectedAgents = useAppStore((s) => s.ensureDetectedAgents) // Why: pet segment is driven purely by experimentalPet, not statusBarItems, to avoid double-toggling the surface (see design doc). const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true) @@ -83,13 +88,13 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { setIsRefreshing(true) try { // Why: re-run PATH detection so a freshly-installed/removed CLI's bar appears/hides without restarting Orca. - await Promise.all([refreshRateLimits(), refreshDetectedAgents()]) + await Promise.all([(refreshRemote ?? refreshRateLimits)(), refreshDetectedAgents()]) } finally { if (mountedRef.current) { setIsRefreshing(false) } } - }, [isRefreshing, refreshRateLimits, refreshDetectedAgents]) + }, [isRefreshing, refreshRemote, refreshRateLimits, refreshDetectedAgents]) if (!statusBarVisible) { return null @@ -101,11 +106,13 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { // Why: a bar is earned by a live snapshot or durable Settings setup; detection-gating hides per-CLI bars when the agent isn't on PATH. // Why: Antigravity has no persisted credential, so a checked status item + detected CLI is the durable "show its slot" signal. const antigravityUsageConfigured = + !remoteOwned && statusBarItems.includes('antigravity') && isStatusBarItemAvailable('antigravity', detectedAgentIds) // Why: thread non-GlobalSettings durability flags so bars stay visible across reloads and snapshot refreshes. + // A remote owner's bars come from its snapshot alone; this viewer's setup says nothing about that host. const usageSettings = { - ...settings, + ...(remoteOwned ? {} : settings), antigravityUsageConfigured, minimaxCookieConfigured: rateLimits.minimaxCookieConfigured, minimaxApiKeyConfigured: rateLimits.minimaxApiKeyConfigured, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7f32b4072f4..4c0d95e3103 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4268,6 +4268,8 @@ "f8f0f9d8cc": "Network issue", "bf2e739f18": "Sign-in unavailable", "f8b8dbed85": "Usage unavailable", + "remoteOwnerUnreachable": "Usage unavailable — cannot reach {{server}}", + "remoteOwnerNoUsage": "Usage unavailable — {{server}} does not report usage. Update Orca on that host.", "3d3c9c0c1f": "Claude usage will refresh after the live Claude terminal rotates its credentials.", "42fdd4da1d": "Claude sign-in is being refreshed. Agent sessions may still be signed in.", "c06c1d215d": "Claude usage could not be refreshed because the network request failed.", diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.ts index 394858ab44b..29902409e67 100644 --- a/src/renderer/src/runtime/runtime-provider-accounts-client.ts +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.ts @@ -71,6 +71,8 @@ export function watchProviderAccounts( handlers: { onSnapshot: (snapshot: ProviderAccountsSnapshot) => void onError: (error: unknown) => void + /** Remote only: the stream ended after its first snapshot, so later usage is unverifiable. */ + onClosedAfterSnapshot?: () => void } ): ProviderAccountsWatcher { if (target.kind === 'local') { @@ -166,8 +168,13 @@ export function watchProviderAccounts( } }, onClose: () => { - if (!closed && !receivedSnapshot) { + if (closed) { + return + } + if (!receivedSnapshot) { handlers.onError(new Error('Remote provider account subscription closed.')) + } else { + handlers.onClosedAfterSnapshot?.() } } } diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 7b30956f47c..1f7f5f64e48 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -70,6 +70,8 @@ export type UsageRateLimitMetadata = { lastSuccessfulSource?: UsageRateLimitSource /** Unix ms timestamp before which usage refetches should not be attempted (from HTTP Retry-After). */ retryAtMs?: number + /** Renderer-only: the remote host that owns this usage cannot vouch for it. Hosts never set it. */ + unverifiableOwner?: boolean } export type ProviderRateLimits = {