mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
fix(status-bar): show usage from the host that owns the accounts (#29430)
* fix(status-bar): show usage from the host that owns the accounts With an SSH-managed or paired Orca server owning the focused workspace's accounts, the usage badges read that host's accounts snapshot instead of this device's local poll. An older host that publishes no usage reads "Usage unavailable", and loss of contact is an unverifiable verdict. * fix(status-bar): keep remote usage fresh and honest across stream loss and owner switches * fix(status-bar): never borrow the viewer's setup flags for a remote usage owner * fix(status-bar): an outage stops a still-fetching remote usage slot from loading * fix(status-bar): route usage by the focused workspace owner and fix a narrowing error
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<UsageProviderKey, ProviderRateLimits['provider']> = {
|
||||
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<UsageProviderKey, …>.
|
||||
const USAGE_PROVIDER_KEYS = Object.keys(USAGE_PROVIDER_IDS) as UsageProviderKey[]
|
||||
|
||||
function mapProviders(
|
||||
build: (key: UsageProviderKey) => ProviderRateLimits | null
|
||||
): Pick<RateLimitState, UsageProviderKey> {
|
||||
// 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<RateLimitState> = 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<UsageProviderKey> = 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<ProviderAccountsSnapshot>((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)
|
||||
})
|
||||
})
|
||||
@@ -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<void>) | 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<OwnedUsage | null>(null)
|
||||
const ownedRef = useRef<OwnedUsage | null>(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<typeof setTimeout> | 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<ProviderAccountsSnapshot>(
|
||||
{ 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<OwnerUsageState>(() => {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user