diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index f03d803c6f8..91c655c234a 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -63,8 +63,8 @@ import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from import { AgentIcon } from '@/lib/agent-catalog' import { UsageRosterPanel, getTightestUsageSection } from './UsageRosterPanel' import { getUsageProviderAccountsSectionId } from './usage-provider-settings-target' -import type { RateLimitState } from '../../../../shared/rate-limit-types' import { resolveStatusBarUsageRateLimits } from './usage-rate-limits-source' +import { useRemoteUsageRateLimits } from './remote-usage-rate-limits' import { formatRateLimitWindowChipLabel } from '@/lib/window-label-formatter' import { useResetCountdownClock } from '@/hooks/useResetCountdownClock' import { @@ -95,10 +95,8 @@ import { import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { fetchProviderAccountsSnapshot, - hasRemoteProviderAccountOwner, selectClaudeProviderAccount, - selectCodexProviderAccount, - watchProviderAccounts + selectCodexProviderAccount } from '@/runtime/runtime-provider-accounts-client' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' @@ -2050,39 +2048,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const [isRefreshing, setIsRefreshing] = useState(false) const [menuOpen, setMenuOpen] = useState(false) // Why (#15798): with a remote Active Server the usage badges must describe the - // machine actually running agents. The provider-accounts snapshot already - // carries the server's full RateLimitState; the watcher streams its 5-minute - // poll refreshes, and the manual refresh button re-fetches the snapshot. - const remoteUsageOwner = hasRemoteProviderAccountOwner(settings) - const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null - const [remoteRateLimits, setRemoteRateLimits] = useState(null) - useEffect(() => { - if (!remoteUsageOwner) { - setRemoteRateLimits(null) - return - } - const watcher = watchProviderAccounts( - { activeRuntimeEnvironmentId }, - { - onSnapshot: (snapshot) => { - if (snapshot.rateLimits) { - setRemoteRateLimits(snapshot.rateLimits) - } - }, - onError: (error) => { - console.error('Failed to stream remote usage snapshot:', error) - } - } - ) - return () => { - watcher.close() - } - }, [remoteUsageOwner, activeRuntimeEnvironmentId]) - const effectiveRateLimits = resolveStatusBarUsageRateLimits( - rateLimits, - remoteRateLimits, - remoteUsageOwner - ) + // machine actually running agents, so they read the owning server's snapshot + // and say so plainly when that server cannot be reached. + const remoteUsage = useRemoteUsageRateLimits(settings) + const effectiveRateLimits = resolveStatusBarUsageRateLimits(rateLimits, remoteUsage.state) const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) const [containerWidth, setContainerWidth] = useState(900) @@ -2131,27 +2100,17 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele } setIsRefreshing(true) try { - // Why (#15798): remote-owned usage refreshes by re-fetching the server's - // snapshot; the local poll would describe the wrong machine. - const usageRefresh = remoteUsageOwner - ? fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId }) - .then((snapshot) => { - if (snapshot.rateLimits) { - setRemoteRateLimits(snapshot.rateLimits) - } - }) - .catch((error) => { - console.error('Failed to refresh remote usage snapshot:', error) - }) - : refreshRateLimits() + // Why (#15798): remote-owned usage refreshes on the server that owns it; + // the local poll would describe the wrong machine. + const usageRefresh = remoteUsage.refresh ?? refreshRateLimits // Why: re-run PATH detection so a freshly-installed/removed CLI's bar appears/hides without restarting Orca. - await Promise.all([usageRefresh, refreshDetectedAgents()]) + await Promise.all([usageRefresh(), refreshDetectedAgents()]) } finally { if (mountedRef.current) { setIsRefreshing(false) } } - }, [isRefreshing, refreshRateLimits, refreshDetectedAgents]) + }, [isRefreshing, remoteUsage.refresh, refreshRateLimits, refreshDetectedAgents]) if (!statusBarVisible) { return null diff --git a/src/renderer/src/components/status-bar/remote-usage-rate-limits.test.ts b/src/renderer/src/components/status-bar/remote-usage-rate-limits.test.ts new file mode 100644 index 00000000000..72049c5d7ba --- /dev/null +++ b/src/renderer/src/components/status-bar/remote-usage-rate-limits.test.ts @@ -0,0 +1,240 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits, RateLimitState } from '../../../../shared/rate-limit-types' +import { useRemoteUsageRateLimits } from './remote-usage-rate-limits' + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string) => fallback +})) + +const mocks = vi.hoisted(() => ({ + runtimeEnvironments: [] as { id: string; name: string }[], + watchers: [] as { + ownerId: string | null + closed: boolean + onSnapshot: (snapshot: { rateLimits: RateLimitState | null }) => void + onError: (error: unknown) => void + }[], + fetchSnapshot: vi.fn() +})) + +vi.mock('../../store', () => { + const state = (): Record => ({ runtimeEnvironments: mocks.runtimeEnvironments }) + const useAppStore = (selector: (value: Record) => unknown): unknown => + selector(state()) + useAppStore.getState = state + return { useAppStore } +}) + +vi.mock('@/runtime/runtime-provider-accounts-client', () => ({ + hasRemoteProviderAccountOwner: ( + settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined + ) => Boolean(settings?.activeRuntimeEnvironmentId?.trim()), + watchProviderAccounts: ( + settings: { activeRuntimeEnvironmentId: string | null }, + handlers: { + onSnapshot: (snapshot: { rateLimits: RateLimitState | null }) => void + onError: (error: unknown) => void + } + ) => { + const watcher = { + ownerId: settings.activeRuntimeEnvironmentId, + closed: false, + onSnapshot: handlers.onSnapshot, + onError: handlers.onError + } + mocks.watchers.push(watcher) + return { + close: () => { + watcher.closed = true + } + } + }, + fetchProviderAccountsSnapshot: (settings: { activeRuntimeEnvironmentId: string | null }) => + mocks.fetchSnapshot(settings) +})) + +function provider(usedPercent: number, updatedAt: number): ProviderRateLimits { + return { + provider: 'claude', + session: { usedPercent, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + updatedAt, + error: null, + status: 'ok' + } +} + +function rateLimits(usedPercent: number, updatedAt: number): RateLimitState { + return { + claude: provider(usedPercent, updatedAt), + codex: null, + gemini: null, + opencodeGo: null, + kimi: null, + antigravity: null, + minimax: null, + grok: null, + minimaxCookieConfigured: false, + grokAuthConfigured: false, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, + inactiveClaudeAccounts: [], + inactiveCodexAccounts: [] + } +} + +function latestWatcher(): (typeof mocks.watchers)[number] { + const watcher = mocks.watchers.at(-1) + if (!watcher) { + throw new Error('no watcher opened') + } + return watcher +} + +describe('useRemoteUsageRateLimits', () => { + beforeEach(() => { + mocks.runtimeEnvironments = [ + { id: 'env-a', name: 'Mac Mini' }, + { id: 'env-b', name: 'Studio' } + ] + mocks.watchers = [] + mocks.fetchSnapshot.mockReset() + }) + + afterEach(() => { + cleanup() + }) + + it('stays local and opens no watcher without a remote Active Server', () => { + const { result } = renderHook(() => + useRemoteUsageRateLimits({ activeRuntimeEnvironmentId: null }) + ) + + expect(result.current.state).toEqual({ kind: 'local' }) + expect(result.current.refresh).toBeNull() + expect(mocks.watchers).toHaveLength(0) + }) + + it('is pending until the owning server sends a snapshot, then reports its numbers', () => { + const { result } = renderHook(() => + useRemoteUsageRateLimits({ activeRuntimeEnvironmentId: 'env-a' }) + ) + + expect(result.current.state).toEqual({ kind: 'remote-pending' }) + + act(() => { + latestWatcher().onSnapshot({ rateLimits: rateLimits(71, 5_000) }) + }) + + expect(result.current.state).toEqual({ kind: 'remote', rateLimits: rateLimits(71, 5_000) }) + }) + + it('reports the owner as unreachable when the watcher fails, and recovers on the next snapshot', () => { + const { result } = renderHook(() => + useRemoteUsageRateLimits({ activeRuntimeEnvironmentId: 'env-a' }) + ) + + act(() => { + latestWatcher().onError(new Error('Timed out waiting for remote provider accounts.')) + }) + + // Why: swallowing this error left the badge spinning forever, which + // docs/reference/ssh-execution-boundary.md forbids - loss of contact is + // 'unverifiable', not 'in progress'. + expect(result.current.state).toEqual({ kind: 'remote-unreachable', ownerLabel: 'Mac Mini' }) + + act(() => { + latestWatcher().onSnapshot({ rateLimits: rateLimits(12, 6_000) }) + }) + + expect(result.current.state.kind).toBe('remote') + }) + + it('falls back to a generic server label for an unknown environment id', () => { + mocks.runtimeEnvironments = [] + const { result } = renderHook(() => + useRemoteUsageRateLimits({ activeRuntimeEnvironmentId: 'env-a' }) + ) + + act(() => { + latestWatcher().onError(new Error('Remote provider account subscription closed.')) + }) + + expect(result.current.state).toEqual({ + kind: 'remote-unreachable', + ownerLabel: 'Remote server' + }) + }) + + it("never renders server A's usage under server B", () => { + const { result, rerender } = renderHook( + (props: { id: string }) => useRemoteUsageRateLimits({ activeRuntimeEnvironmentId: props.id }), + { initialProps: { id: 'env-a' } } + ) + + const watcherA = latestWatcher() + act(() => { + watcherA.onSnapshot({ rateLimits: rateLimits(71, 5_000) }) + }) + expect(result.current.state.kind).toBe('remote') + + rerender({ id: 'env-b' }) + + expect(watcherA.closed).toBe(true) + expect(result.current.state).toEqual({ kind: 'remote-pending' }) + + // A late in-flight callback from A must not land under B's label. + act(() => { + watcherA.onSnapshot({ rateLimits: rateLimits(99, 9_000) }) + }) + expect(result.current.state).toEqual({ kind: 'remote-pending' }) + }) + + it('keeps the refresh promise open until data newer than the click arrives', async () => { + const { result } = renderHook(() => + useRemoteUsageRateLimits({ activeRuntimeEnvironmentId: 'env-a' }) + ) + act(() => { + latestWatcher().onSnapshot({ rateLimits: rateLimits(71, 5_000) }) + }) + + // Why: fetchProviderAccountsSnapshot resolves off the subscribe-time `ready` + // frame, which the host emits *before* it re-reads usage. Resolving there + // would stop the spinner before any newer data can exist. + let settled = false + mocks.fetchSnapshot.mockResolvedValue({ rateLimits: rateLimits(71, 5_000) }) + const refresh = result.current.refresh + expect(refresh).not.toBeNull() + + await act(async () => { + const pending = refresh!().then(() => { + settled = true + }) + await Promise.resolve() + await Promise.resolve() + expect(settled).toBe(false) + latestWatcher().onSnapshot({ rateLimits: rateLimits(73, 7_000) }) + await pending + }) + + expect(settled).toBe(true) + expect(mocks.fetchSnapshot).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: 'env-a' }) + expect(result.current.state).toEqual({ kind: 'remote', rateLimits: rateLimits(73, 7_000) }) + }) + + it('reports the owner as unreachable when a manual refresh cannot reach it', async () => { + const { result } = renderHook(() => + useRemoteUsageRateLimits({ activeRuntimeEnvironmentId: 'env-a' }) + ) + mocks.fetchSnapshot.mockRejectedValue(new Error('offline')) + + await act(async () => { + await result.current.refresh!() + }) + + expect(result.current.state).toEqual({ kind: 'remote-unreachable', ownerLabel: 'Mac Mini' }) + }) +}) diff --git a/src/renderer/src/components/status-bar/remote-usage-rate-limits.ts b/src/renderer/src/components/status-bar/remote-usage-rate-limits.ts new file mode 100644 index 00000000000..29c97a162aa --- /dev/null +++ b/src/renderer/src/components/status-bar/remote-usage-rate-limits.ts @@ -0,0 +1,170 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { translate } from '@/i18n/i18n' +import { + fetchProviderAccountsSnapshot, + hasRemoteProviderAccountOwner, + watchProviderAccounts +} from '@/runtime/runtime-provider-accounts-client' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { RateLimitState } from '../../../../shared/rate-limit-types' +import { useAppStore } from '../../store' +import { latestUsageUpdatedAt, type RemoteUsageState } from './usage-rate-limits-source' + +type RemoteUsageSettings = Pick | null | undefined + +export type RemoteUsageRateLimits = { + state: RemoteUsageState + /** + * Forces the owning server to re-read usage, or null when the local machine + * owns it and the caller should run its own refresh instead. + */ + refresh: (() => Promise) | null +} + +/** + * A snapshot is only meaningful next to the server it came from: switching + * server A -> B closes A's watcher but an in-flight callback can still land, + * and the old numbers would render under B's name. + */ +type OwnedUsageSnapshot = { + ownerId: string | null + rateLimits: RateLimitState | null + unreachable: boolean +} + +// Why: a fresh accounts.subscribe makes the host run refreshIfStale, but the +// `ready` frame it answers with is emitted *before* that refresh. Hold the +// spinner until newer data actually arrives, and cap the wait so a host that +// throttled the refetch (or has nothing new) still releases the button. +const REMOTE_USAGE_REFRESH_SETTLE_TIMEOUT_MS = 8_000 + +export function resolveRemoteUsageOwnerLabel( + environments: readonly { id: string; name?: string | null }[] | null | undefined, + ownerId: string | null +): string { + const name = environments?.find((environment) => environment.id === ownerId)?.name?.trim() + return ( + name || translate('auto.components.status.bar.StatusBar.remoteServerLabel', 'Remote server') + ) +} + +/** + * Streams the usage numbers of whichever machine owns provider accounts (#15798). + * + * The provider-accounts snapshot the accounts roster already consumes carries + * the server's full RateLimitState, so no new RPC or stream opcode is needed. + * A watcher error resolves to `remote-unreachable` rather than an endless + * spinner: per docs/reference/ssh-execution-boundary.md, losing contact with + * the execution host is its own verdict and must not be rendered as progress. + */ +export function useRemoteUsageRateLimits(settings: RemoteUsageSettings): RemoteUsageRateLimits { + const remoteUsageOwner = hasRemoteProviderAccountOwner(settings) + const ownerId = settings?.activeRuntimeEnvironmentId?.trim() || null + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) + const [owned, setOwned] = useState(null) + const ownedRef = useRef(null) + const pendingRefreshRef = useRef<{ afterUpdatedAt: number; settle: () => void } | null>(null) + + const applyOwned = useCallback((next: OwnedUsageSnapshot) => { + ownedRef.current = next + setOwned(next) + const pending = pendingRefreshRef.current + if (pending && latestUsageUpdatedAt(next.rateLimits) > pending.afterUpdatedAt) { + pending.settle() + } + }, []) + + useEffect(() => { + ownedRef.current = null + setOwned(null) + pendingRefreshRef.current?.settle() + if (!remoteUsageOwner) { + return + } + let active = true + const watcher = watchProviderAccounts( + { activeRuntimeEnvironmentId: ownerId }, + { + onSnapshot: (snapshot) => { + if (!active || !snapshot.rateLimits) { + return + } + applyOwned({ ownerId, rateLimits: snapshot.rateLimits, unreachable: false }) + }, + onError: () => { + if (!active) { + return + } + // Why: the watcher reports a first-snapshot timeout, a closed + // subscription and RPC failures through this one channel. All of them + // mean the same thing for the badge - the owner cannot be reached, so + // its usage is unverifiable, not zero and not still loading. + applyOwned({ ownerId, rateLimits: null, unreachable: true }) + } + } + ) + return () => { + active = false + watcher.close() + pendingRefreshRef.current?.settle() + } + }, [remoteUsageOwner, ownerId, applyOwned]) + + const refresh = useCallback(async () => { + const current = ownedRef.current + const afterUpdatedAt = + current?.ownerId === ownerId ? latestUsageUpdatedAt(current.rateLimits) : 0 + let resolveSettled: () => void = () => {} + const settled = new Promise((resolve) => { + resolveSettled = resolve + }) + let timer = 0 + const entry = { + afterUpdatedAt, + settle: (): void => { + window.clearTimeout(timer) + if (pendingRefreshRef.current === entry) { + pendingRefreshRef.current = null + } + resolveSettled() + } + } + timer = window.setTimeout(entry.settle, REMOTE_USAGE_REFRESH_SETTLE_TIMEOUT_MS) + pendingRefreshRef.current?.settle() + pendingRefreshRef.current = entry + try { + const snapshot = await fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId: ownerId }) + if (snapshot.rateLimits) { + applyOwned({ ownerId, rateLimits: snapshot.rateLimits, unreachable: false }) + } + } catch { + applyOwned({ ownerId, rateLimits: null, unreachable: true }) + entry.settle() + } + await settled + }, [ownerId, applyOwned]) + + const ownerLabel = useMemo( + () => resolveRemoteUsageOwnerLabel(runtimeEnvironments, ownerId), + [runtimeEnvironments, ownerId] + ) + + const state = useMemo(() => { + if (!remoteUsageOwner) { + return { kind: 'local' } + } + // Why: a render can run with the new owner before the effect has torn the + // old watcher down, so a snapshot is only used under the owner it names. + if (!owned || owned.ownerId !== ownerId) { + return { kind: 'remote-pending' } + } + if (owned.rateLimits) { + return { kind: 'remote', rateLimits: owned.rateLimits } + } + return owned.unreachable + ? { kind: 'remote-unreachable', ownerLabel } + : { kind: 'remote-pending' } + }, [remoteUsageOwner, owned, ownerId, ownerLabel]) + + return { state, refresh: remoteUsageOwner ? refresh : null } +} 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 f1afd97f5f4..5e94ad52ece 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 @@ -115,7 +115,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-rate-limits-source.test.ts b/src/renderer/src/components/status-bar/usage-rate-limits-source.test.ts index 31a00bd7b63..6acd3754540 100644 --- a/src/renderer/src/components/status-bar/usage-rate-limits-source.test.ts +++ b/src/renderer/src/components/status-bar/usage-rate-limits-source.test.ts @@ -1,62 +1,178 @@ -import { describe, expect, it } from 'vitest' -import type { RateLimitState } from '../../../../shared/rate-limit-types' -import { resolveStatusBarUsageRateLimits } from './usage-rate-limits-source' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits, RateLimitState } from '../../../../shared/rate-limit-types' +import { latestUsageUpdatedAt, resolveStatusBarUsageRateLimits } from './usage-rate-limits-source' + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replaceAll(`{{${key}}}`, value) + } + return result + } +})) + +const PROVIDER_KEYS = [ + 'claude', + 'codex', + 'gemini', + 'opencodeGo', + 'kimi', + 'antigravity', + 'minimax', + 'grok' +] as const + +function providerWithUsage( + provider: ProviderRateLimits['provider'], + usedPercent: number, + updatedAt: number +): ProviderRateLimits { + return { + provider, + session: { usedPercent, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + updatedAt, + // Why: the reported symptom is a *valid-looking* local snapshot leaking, so + // the fixture carries both real windows and a stale local error string. + error: 'Refresh failed', + status: 'error' + } +} function localState(): RateLimitState { return { - claude: { - provider: 'claude', - session: null, - weekly: null, - updatedAt: 1, - error: 'Refresh failed', - status: 'error' - }, - codex: null, - gemini: null, - opencodeGo: null, - kimi: null, - antigravity: null, - minimax: null, - grok: null, + claude: providerWithUsage('claude', 42, 1_000), + codex: providerWithUsage('codex', 43, 1_000), + gemini: providerWithUsage('gemini', 44, 1_000), + opencodeGo: providerWithUsage('opencode-go', 45, 1_000), + kimi: providerWithUsage('kimi', 46, 1_000), + antigravity: providerWithUsage('antigravity', 47, 1_000), + minimax: providerWithUsage('minimax', 48, 1_000), + grok: providerWithUsage('grok', 49, 1_000), minimaxCookieConfigured: true, - grokAuthConfigured: false, + grokAuthConfigured: true, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], inactiveCodexAccounts: [] - } as RateLimitState + } } function remoteState(): RateLimitState { - return { ...localState(), minimaxCookieConfigured: false } + return { + ...localState(), + claude: providerWithUsage('claude', 71, 5_000), + minimaxCookieConfigured: false, + grokAuthConfigured: false + } } describe('resolveStatusBarUsageRateLimits', () => { it('shows local usage when no remote Active Server owns accounts', () => { const local = localState() - expect(resolveStatusBarUsageRateLimits(local, remoteState(), false)).toBe(local) + expect(resolveStatusBarUsageRateLimits(local, { kind: 'local' })).toBe(local) }) - it('shows the remote snapshot when a remote Active Server owns accounts (#15798)', () => { - const local = localState() - const remote = remoteState() - const resolved = resolveStatusBarUsageRateLimits(local, remote, true) - expect(resolved).toBe(remote) + it("shows the owning server's numbers once its snapshot lands (#15798)", () => { + const resolved = resolveStatusBarUsageRateLimits(localState(), { + kind: 'remote', + rateLimits: remoteState() + }) + expect(resolved.claude?.session?.usedPercent).toBe(71) + // Why: the server owns sign-in state too, so its flags win when it sends them. + expect(resolved.minimaxCookieConfigured).toBe(false) + expect(resolved.grokAuthConfigured).toBe(false) }) - it('degrades to a fetching state rather than flashing local numbers before the first snapshot', () => { - const local = localState() - const resolved = resolveStatusBarUsageRateLimits(local, null, true) - expect(resolved.claude).toMatchObject({ status: 'fetching' }) - // The stale local error must not read as the remote machine's state. - expect(resolved.claude).not.toMatchObject({ status: 'error' }) - // Durability flags stay local so provider bars keep their visibility rules. + it('falls back to local durability flags when the host predates them', () => { + // Why: RateLimitState declares both flags as required, but an older remote + // Orca omits them entirely. Reading them raw would hide configured bars. + const preFlagHost = remoteState() + delete (preFlagHost as Partial).minimaxCookieConfigured + delete (preFlagHost as Partial).grokAuthConfigured + + const resolved = resolveStatusBarUsageRateLimits(localState(), { + kind: 'remote', + rateLimits: preFlagHost + }) + expect(resolved.minimaxCookieConfigured).toBe(true) + expect(resolved.grokAuthConfigured).toBe(true) }) - it('keeps a null provider null in the fetching degrade', () => { - const resolved = resolveStatusBarUsageRateLimits(localState(), null, true) - expect(resolved.codex).toBeNull() + it('never renders local percentages while the first remote snapshot is in flight', () => { + const resolved = resolveStatusBarUsageRateLimits(localState(), { kind: 'remote-pending' }) + + // Why: #15798 calls valid-looking local numbers "arguably worse than an + // error state". Every provider must be blank, not just the null ones. + for (const key of PROVIDER_KEYS) { + const provider = resolved[key] + expect(provider?.status).toBe('fetching') + expect(provider?.session).toBeNull() + expect(provider?.weekly).toBeNull() + expect(provider?.error).toBeNull() + expect(provider?.updatedAt).toBe(0) + } + }) + + it('reports an unreachable owner instead of a spinner or a healthy bar', () => { + const resolved = resolveStatusBarUsageRateLimits(localState(), { + kind: 'remote-unreachable', + ownerLabel: 'Mac Mini' + }) + + for (const key of PROVIDER_KEYS) { + const provider = resolved[key] + // docs/reference/ssh-execution-boundary.md: loss of contact is its own + // verdict — never 0%, never "still loading". + expect(provider?.status).toBe('error') + expect(provider?.error).toBe('Usage unavailable — cannot reach Mac Mini') + expect(provider?.session).toBeNull() + expect(provider?.weekly).toBeNull() + } + }) +}) + +describe('latestUsageUpdatedAt', () => { + it('returns the newest provider timestamp', () => { + expect(latestUsageUpdatedAt(remoteState())).toBe(5_000) + }) + + it('returns 0 when nothing has landed', () => { + expect(latestUsageUpdatedAt(null)).toBe(0) + }) +}) + +/** + * Why a source assertion: the resolver is pure, so reverting StatusBar back to + * the raw `rateLimits` store slice would leave every behavioural test above + * green while restoring the reported bug. This pins the one wiring line that + * decides which machine the badges describe. + */ +describe('StatusBar badge source wiring', () => { + const source = readFileSync(resolve(__dirname, 'StatusBar.tsx'), 'utf8') + + it('destructures the rendered providers from the resolved state', () => { + expect(source).toMatch( + /const \{ claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok \} =\s*\n?\s*effectiveRateLimits/ + ) + }) + + it('reads the durability flags from the resolved state', () => { + expect(source).toContain('minimaxCookieConfigured: effectiveRateLimits.minimaxCookieConfigured') + expect(source).toContain('grokAuthConfigured: effectiveRateLimits.grokAuthConfigured') + }) + + it('rebuilds the refresh callback when the owning server changes', () => { + // Why: refreshRateLimits/refreshDetectedAgents are stable store selectors, + // so without the hook's owner-keyed refresh in the deps the closure would + // keep refreshing the machine that was active when it was first created. + expect(source).toContain( + '}, [isRefreshing, remoteUsage.refresh, refreshRateLimits, refreshDetectedAgents])' + ) }) }) diff --git a/src/renderer/src/components/status-bar/usage-rate-limits-source.ts b/src/renderer/src/components/status-bar/usage-rate-limits-source.ts index 73f0f7e97c6..77e1036fc12 100644 --- a/src/renderer/src/components/status-bar/usage-rate-limits-source.ts +++ b/src/renderer/src/components/status-bar/usage-rate-limits-source.ts @@ -1,33 +1,133 @@ -import type { RateLimitState } from '../../../../shared/rate-limit-types' +import type { ProviderRateLimits, RateLimitState } from '../../../../shared/rate-limit-types' +import { translate } from '@/i18n/i18n' +import { createPendingProviderSnapshot } from './status-bar-provider-visibility' /** * Which machine's usage the status-bar badges describe (#15798). * - * With a remote Active Server, agents run there and the provider-accounts - * snapshot carries the server's full RateLimitState — the badges must show - * those numbers, not the viewer's local poll (which describes a machine that - * runs nothing and can look correct while being wrong). While the first - * remote snapshot is still in flight, the Claude/Codex rows degrade to a - * fetching state rather than flashing local numbers. + * With a remote Active Server the agents run there, so the badges must render + * that server's numbers — the viewer's local poll describes a machine that runs + * nothing and can look perfectly healthy while being wrong. Loss of contact is + * its own verdict: per docs/reference/ssh-execution-boundary.md it may not be + * collapsed into "still loading" or into a 0%/healthy bar. */ -export function resolveStatusBarUsageRateLimits( - localRateLimits: RateLimitState, - remoteRateLimits: RateLimitState | null, - remoteUsageOwner: boolean -): RateLimitState { - if (!remoteUsageOwner) { - return localRateLimits - } - if (remoteRateLimits) { - return remoteRateLimits +export type RemoteUsageState = + | { kind: 'local' } + /** A remote server owns usage and its first snapshot has not landed yet. */ + | { kind: 'remote-pending' } + /** The server that owns usage cannot be reached, so its usage is unverifiable. */ + | { kind: 'remote-unreachable'; ownerLabel: string } + | { kind: 'remote'; rateLimits: RateLimitState } + +/** + * Keys of RateLimitState that hold a provider snapshot. Derived rather than + * listed so a newly added provider is a compile error in the maps below instead + * of a silent leak of local numbers under a remote owner. + */ +type UsageProviderKey = { + [K in keyof RateLimitState]-?: RateLimitState[K] extends ProviderRateLimits | null ? K : never +}[keyof RateLimitState] + +const USAGE_PROVIDER_IDS: Record = { + claude: 'claude', + codex: 'codex', + gemini: 'gemini', + opencodeGo: 'opencode-go', + kimi: 'kimi', + antigravity: 'antigravity', + minimax: 'minimax', + grok: 'grok' +} + +const USAGE_PROVIDER_KEYS = Object.keys(USAGE_PROVIDER_IDS) as UsageProviderKey[] + +// Why: the host may predate a field the client now reads. RateLimitState types +// these as required, so read the remote copy through a Partial to keep the +// undefined branch reachable instead of letting a bar silently vanish. +type PartialRemoteRateLimitState = Partial + +function replaceProviders( + build: (providerId: ProviderRateLimits['provider']) => ProviderRateLimits +): Pick { + const replaced = {} as Pick + for (const key of USAGE_PROVIDER_KEYS) { + replaced[key] = build(USAGE_PROVIDER_IDS[key]) } + return replaced +} + +function createUnreachableProviderSnapshot( + providerId: ProviderRateLimits['provider'], + ownerLabel: string +): ProviderRateLimits { return { - ...localRateLimits, - claude: markProviderFetching(localRateLimits.claude), - codex: markProviderFetching(localRateLimits.codex) + ...createPendingProviderSnapshot(providerId), + error: translate( + 'auto.components.status.bar.usage.remoteOwnerUnreachable', + 'Usage unavailable — cannot reach {{server}}', + { server: ownerLabel } + ), + status: 'error' } } -function markProviderFetching(provider: T): T | null { - return provider ? { ...provider, status: 'fetching' } : null +function normalizeFlag(remoteValue: boolean | undefined, localValue: boolean): boolean { + return typeof remoteValue === 'boolean' ? remoteValue : localValue +} + +function adoptRemoteRateLimits(local: RateLimitState, remote: RateLimitState): RateLimitState { + const partial = remote as PartialRemoteRateLimitState + return { + ...remote, + // Why: MiniMax/Grok sign-in lives on disk, so a host older than those + // providers omits the flags entirely; falling back to the local value keeps + // a configured bar visible instead of making it disappear on the first + // remote snapshot. + minimaxCookieConfigured: normalizeFlag( + partial.minimaxCookieConfigured, + local.minimaxCookieConfigured + ), + grokAuthConfigured: normalizeFlag(partial.grokAuthConfigured, local.grokAuthConfigured), + claudeTarget: partial.claudeTarget ?? local.claudeTarget, + codexTarget: partial.codexTarget ?? local.codexTarget, + inactiveClaudeAccounts: partial.inactiveClaudeAccounts ?? [], + inactiveCodexAccounts: partial.inactiveCodexAccounts ?? [] + } +} + +export function resolveStatusBarUsageRateLimits( + localRateLimits: RateLimitState, + remoteUsage: RemoteUsageState +): RateLimitState { + if (remoteUsage.kind === 'local') { + return localRateLimits + } + if (remoteUsage.kind === 'remote') { + return adoptRemoteRateLimits(localRateLimits, remoteUsage.rateLimits) + } + // Why: never spread the local provider here. Keeping any local window would + // render the viewer's percentages under the server's name — the exact + // "looks correct, is wrong" failure #15798 reports. + const providers = + remoteUsage.kind === 'remote-unreachable' + ? replaceProviders((providerId) => + createUnreachableProviderSnapshot(providerId, remoteUsage.ownerLabel) + ) + : replaceProviders(createPendingProviderSnapshot) + return { ...localRateLimits, ...providers } +} + +/** Newest provider snapshot timestamp in a state, or 0 when nothing has landed. */ +export function latestUsageUpdatedAt(rateLimits: RateLimitState | null): number { + if (!rateLimits) { + return 0 + } + let newest = 0 + for (const key of USAGE_PROVIDER_KEYS) { + const provider = rateLimits[key] + if (provider && provider.updatedAt > newest) { + newest = provider.updatedAt + } + } + return newest } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8e8cf2ab5e2..650db29e1ca 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3756,6 +3756,9 @@ "onDescription": "Keep this computer awake continuously", "autoDescription": "Stay awake while an agent is working", "offDescription": "Allow normal system sleep behavior" + }, + "usage": { + "remoteOwnerUnreachable": "Usage unavailable — cannot reach {{server}}" } } },