mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 16:17:51 +00:00
fix(status-bar): make remote usage badges honest and owner-scoped
Blank every provider while the owning server's first snapshot is in flight. Overwriting only `status` left the local session/weekly windows in place, so getTightestUsageSection() still returned data and the badge kept rendering the viewer's percentages under the server's name — the exact "looks correct, is wrong" symptom #15798 reports. The degrade also skipped gemini/opencodeGo/kimi/antigravity/minimax/grok; it is now driven by a compile-forced map over RateLimitState's provider keys. Surface watcher failures instead of console.error: a first-snapshot timeout, a closed subscription or an RPC error now resolve to "Usage unavailable — cannot reach <server>". Per docs/reference/ssh-execution-boundary.md, loss of contact with the execution host is its own verdict and must not render as a spinner. Scope the cached snapshot to its owner so switching server A -> B cannot show A's numbers under B, keep the MiniMax/Grok durability flags local when the host is too old to publish them, and hold the refresh spinner until data newer than the click arrives rather than clearing on the subscribe-time `ready` frame. The subscription moved out of StatusBar.tsx into useRemoteUsageRateLimits so the owner-switch and unreachable paths are unit-testable. Co-authored-by: yzxcj797 <1784931579@qq.com>
This commit is contained in:
@@ -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<RateLimitState | null>(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
|
||||
|
||||
@@ -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<string, unknown> => ({ runtimeEnvironments: mocks.runtimeEnvironments })
|
||||
const useAppStore = (selector: (value: Record<string, unknown>) => 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' })
|
||||
})
|
||||
})
|
||||
@@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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<void>) | 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<OwnedUsageSnapshot | null>(null)
|
||||
const ownedRef = useRef<OwnedUsageSnapshot | null>(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<void>((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<RemoteUsageState>(() => {
|
||||
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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string>) => {
|
||||
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<RateLimitState>).minimaxCookieConfigured
|
||||
delete (preFlagHost as Partial<RateLimitState>).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])'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<UsageProviderKey, ProviderRateLimits['provider']> = {
|
||||
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<RateLimitState>
|
||||
|
||||
function replaceProviders(
|
||||
build: (providerId: ProviderRateLimits['provider']) => ProviderRateLimits
|
||||
): Pick<RateLimitState, UsageProviderKey> {
|
||||
const replaced = {} as Pick<RateLimitState, UsageProviderKey>
|
||||
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<T extends { status: string } | null>(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
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user