From f47832cce0b65d2c40efd7c13be28aa5744457fc Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 17 Jun 2026 04:29:45 +0800 Subject: [PATCH] fix(mobile): show usage bars for system-default login (#5499) Show mobile account usage for Claude/Codex system-default logins when rate-limit data exists without Orca-managed accounts.\n\nReview fixes:\n- match inactive account usage to the runtime rateLimits payload shape\n- share usage-bar state so stale window data remains visible during transient fetch errors\n- add regression coverage for both cases --- .../app/h/[hostId]/accounts-screen-styles.ts | 137 +++++++++++++ mobile/app/h/[hostId]/accounts.tsx | 180 ++++-------------- mobile/app/index.tsx | 38 ++-- mobile/src/components/AccountUsage.tsx | 89 ++------- .../components/account-usage-state.test.ts | 141 ++++++++++++++ mobile/src/components/account-usage-state.ts | 129 +++++++++++++ 6 files changed, 481 insertions(+), 233 deletions(-) create mode 100644 mobile/app/h/[hostId]/accounts-screen-styles.ts create mode 100644 mobile/src/components/account-usage-state.test.ts create mode 100644 mobile/src/components/account-usage-state.ts diff --git a/mobile/app/h/[hostId]/accounts-screen-styles.ts b/mobile/app/h/[hostId]/accounts-screen-styles.ts new file mode 100644 index 00000000000..4a987b7a442 --- /dev/null +++ b/mobile/app/h/[hostId]/accounts-screen-styles.ts @@ -0,0 +1,137 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme' + +export const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.sm, + gap: spacing.sm + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center' + }, + iconButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center' + }, + titleWrap: { + flex: 1 + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + subheading: { + fontSize: typography.metaSize, + color: colors.textSecondary, + marginTop: 1 + }, + scroll: { + paddingHorizontal: spacing.lg, + paddingTop: spacing.sm + }, + section: { + marginBottom: spacing.xl + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginBottom: spacing.sm + }, + sectionHeading: { + fontSize: typography.metaSize, + fontWeight: '600', + color: colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.5 + }, + card: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden' + }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowMain: { + flex: 1, + gap: 4 + }, + // Why: fixed-width trailing slot so the usage bars in `rowMain` keep the + // same width whether or not the row is currently selected (otherwise the + // checkmark on the active account squeezes the bars narrower than the + // inactive rows above/below it). + rowTrailing: { + width: 24, + alignItems: 'flex-end', + justifyContent: 'center', + marginLeft: spacing.sm + }, + rowTitle: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowSubtitle: { + fontSize: typography.metaSize, + color: colors.textSecondary + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + usageRow: { + flexDirection: 'row', + gap: spacing.md, + marginTop: 4 + }, + errorText: { + fontSize: typography.metaSize, + color: colors.statusRed + }, + placeholder: { + paddingVertical: spacing.xl * 2, + alignItems: 'center', + gap: spacing.sm + }, + placeholderText: { + fontSize: typography.bodySize, + color: colors.textSecondary + }, + footerHint: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm, + paddingHorizontal: spacing.sm, + paddingTop: spacing.sm + }, + footerHintText: { + flex: 1, + fontSize: typography.metaSize, + color: colors.textMuted, + lineHeight: 18 + } +}) diff --git a/mobile/app/h/[hostId]/accounts.tsx b/mobile/app/h/[hostId]/accounts.tsx index e38c6579336..2a5f78abfa8 100644 --- a/mobile/app/h/[hostId]/accounts.tsx +++ b/mobile/app/h/[hostId]/accounts.tsx @@ -2,7 +2,6 @@ import { useEffect, useState, useCallback } from 'react' import { View, Text, - StyleSheet, Pressable, ScrollView, ActivityIndicator, @@ -15,13 +14,16 @@ import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native' import { loadHosts } from '../../../src/transport/host-store' import { useHostClient } from '../../../src/transport/client-context' import type { RpcSuccess } from '../../../src/transport/types' -import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme' +import { colors, spacing } from '../../../src/theme/mobile-theme' +import { styles } from './accounts-screen-styles' import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons' import { type AccountsSnapshot, type ProviderKey, getActiveProviderRateLimits, getInactiveProviderUsage, + getUsageBarState, + hasActiveProviderUsage, UsageBar } from '../../../src/components/AccountUsage' @@ -132,6 +134,8 @@ export default function AccountsScreen() { } const state = provider === 'claude' ? snapshot.claude : snapshot.codex const activeUsage = getActiveProviderRateLimits(snapshot, provider) + const activeSessionBar = getUsageBarState(activeUsage, 'session') + const activeWeeklyBar = getUsageBarState(activeUsage, 'weekly') const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon return ( @@ -149,6 +153,25 @@ export default function AccountsScreen() { System default Use the agent's own login + {/* Why: when system default is the active selection, activeUsage + holds the system-default login's rate limits — surface them + here so non-managed users still see their usage. */} + {state.activeAccountId === null && hasActiveProviderUsage(activeUsage) ? ( + + + + + ) : null} {state.activeAccountId === null ? ( @@ -164,12 +187,12 @@ export default function AccountsScreen() { const inactiveEntry = !isActive ? getInactiveProviderUsage(snapshot, provider, account.id) : null - const usage = isActive ? activeUsage : (inactiveEntry?.claude ?? null) + const usage = isActive ? activeUsage : (inactiveEntry?.rateLimits ?? null) const isFetching = (isActive && usage?.status === 'fetching') || (!isActive && inactiveEntry?.isFetching === true) - const session = usage?.session - const weekly = usage?.weekly + const sessionBar = getUsageBarState(usage, 'session', isFetching) + const weeklyBar = getUsageBarState(usage, 'weekly', isFetching) return ( @@ -185,15 +208,15 @@ export default function AccountsScreen() { {usage?.error ? ( @@ -285,138 +308,3 @@ export default function AccountsScreen() { ) } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase - }, - topRow: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.md, - paddingTop: spacing.sm, - paddingBottom: spacing.sm, - gap: spacing.sm - }, - backButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center' - }, - iconButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center' - }, - titleWrap: { - flex: 1 - }, - heading: { - fontSize: 20, - fontWeight: '700', - color: colors.textPrimary - }, - subheading: { - fontSize: typography.metaSize, - color: colors.textSecondary, - marginTop: 1 - }, - scroll: { - paddingHorizontal: spacing.lg, - paddingTop: spacing.sm - }, - section: { - marginBottom: spacing.xl - }, - sectionHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - marginBottom: spacing.sm - }, - sectionHeading: { - fontSize: typography.metaSize, - fontWeight: '600', - color: colors.textSecondary, - textTransform: 'uppercase', - letterSpacing: 0.5 - }, - card: { - backgroundColor: colors.bgPanel, - borderRadius: radii.card, - overflow: 'hidden' - }, - row: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowPressed: { - backgroundColor: colors.bgRaised - }, - rowMain: { - flex: 1, - gap: 4 - }, - // Why: fixed-width trailing slot so the usage bars in `rowMain` keep the - // same width whether or not the row is currently selected (otherwise the - // checkmark on the active account squeezes the bars narrower than the - // inactive rows above/below it). - rowTrailing: { - width: 24, - alignItems: 'flex-end', - justifyContent: 'center', - marginLeft: spacing.sm - }, - rowTitle: { - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textPrimary - }, - rowSubtitle: { - fontSize: typography.metaSize, - color: colors.textSecondary - }, - separator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - }, - usageRow: { - flexDirection: 'row', - gap: spacing.md, - marginTop: 4 - }, - errorText: { - fontSize: typography.metaSize, - color: colors.statusRed - }, - placeholder: { - paddingVertical: spacing.xl * 2, - alignItems: 'center', - gap: spacing.sm - }, - placeholderText: { - fontSize: typography.bodySize, - color: colors.textSecondary - }, - footerHint: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: spacing.sm, - paddingHorizontal: spacing.sm, - paddingTop: spacing.sm - }, - footerHintText: { - flex: 1, - fontSize: typography.metaSize, - color: colors.textMuted, - lineHeight: 18 - } -}) diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 2e5b04298c6..acaf97b7646 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -19,6 +19,9 @@ import { type AccountsSnapshot, type ProviderKey, getActiveProviderRateLimits, + getUsageBarState, + hasActiveProviderUsage, + hasRenderableUsage, UsageBar } from '../src/components/AccountUsage' import AsyncStorage from '@react-native-async-storage/async-storage' @@ -609,9 +612,10 @@ export default function HomeScreen() { if (!snap) { continue } - const hasClaude = snap.claude.accounts.length > 0 - const hasCodex = snap.codex.accounts.length > 0 - if (hasClaude || hasCodex) { + // Why: also show hosts whose only usage is the system-default login + // (no Orca-managed accounts but live rate-limit data for the active + // target), otherwise system-default users see no usage section at all. + if (hasRenderableUsage(snap, 'claude') || hasRenderableUsage(snap, 'codex')) { items.push({ host, snapshot: snap }) } } @@ -979,16 +983,16 @@ export default function HomeScreen() { provider === 'claude' ? snapshot.claude.accounts : snapshot.codex.accounts - if (accounts.length === 0) { + const limits = getActiveProviderRateLimits(snapshot, provider) + // Why: with no managed accounts, still render a + // "System default" row when the active target has + // live usage data; the row label already falls back + // to "System default" below. + if (accounts.length === 0 && !hasActiveProviderUsage(limits)) { return null } - const limits = getActiveProviderRateLimits(snapshot, provider) - const isFetching = - limits?.status === 'fetching' || limits?.status === 'idle' - const unavailable = - limits == null || - limits.status === 'unavailable' || - limits.status === 'error' + const sessionBar = getUsageBarState(limits, 'session') + const weeklyBar = getUsageBarState(limits, 'weekly') return ( @@ -1005,15 +1009,15 @@ export default function HomeScreen() { diff --git a/mobile/src/components/AccountUsage.tsx b/mobile/src/components/AccountUsage.tsx index 0ff706638b9..9d85bf434b7 100644 --- a/mobile/src/components/AccountUsage.tsx +++ b/mobile/src/components/AccountUsage.tsx @@ -1,76 +1,25 @@ import { View, Text, StyleSheet, ActivityIndicator } from 'react-native' import { colors, spacing, typography } from '../theme/mobile-theme' -// Why: keep these shapes in lockstep with src/shared/types.ts and -// src/shared/rate-limit-types.ts. We don't import from desktop here because -// the mobile bundle must not pull in Electron-coupled type files. -export type RateLimitWindow = { - usedPercent: number - windowMinutes: number - resetsAt: number | null - resetDescription: string | null -} - -export type ProviderRateLimits = { - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' - session: RateLimitWindow | null - weekly: RateLimitWindow | null - monthly?: RateLimitWindow | null - updatedAt: number - error: string | null - status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable' -} - -export type InactiveAccountUsage = { - accountId: string - claude: ProviderRateLimits | null - updatedAt: number - isFetching: boolean -} - -export type ClaudeAccountSummary = { - id: string - email: string - organizationName?: string | null -} - -export type CodexAccountSummary = { - id: string - email: string - workspaceLabel?: string | null -} - -export type AccountsSnapshot = { - claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null } - codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null } - rateLimits: { - claude: ProviderRateLimits | null - codex: ProviderRateLimits | null - inactiveClaudeAccounts: InactiveAccountUsage[] - inactiveCodexAccounts: InactiveAccountUsage[] - } -} - -export type ProviderKey = 'claude' | 'codex' - -export function getActiveProviderRateLimits( - snapshot: AccountsSnapshot, - provider: ProviderKey -): ProviderRateLimits | null { - return provider === 'claude' ? snapshot.rateLimits.claude : snapshot.rateLimits.codex -} - -export function getInactiveProviderUsage( - snapshot: AccountsSnapshot, - provider: ProviderKey, - accountId: string -): InactiveAccountUsage | null { - const list = - provider === 'claude' - ? snapshot.rateLimits.inactiveClaudeAccounts - : snapshot.rateLimits.inactiveCodexAccounts - return list.find((u) => u.accountId === accountId) ?? null -} +// Pure types and selectors live in account-usage-state.ts (no RN imports) so +// they are unit-testable; re-exported here so existing import sites are stable. +export type { + RateLimitWindow, + ProviderRateLimits, + InactiveAccountUsage, + ClaudeAccountSummary, + CodexAccountSummary, + AccountsSnapshot, + ProviderKey, + UsageBarState +} from './account-usage-state' +export { + getActiveProviderRateLimits, + getInactiveProviderUsage, + getUsageBarState, + hasActiveProviderUsage, + hasRenderableUsage +} from './account-usage-state' // Why: matches desktop StatusBar convention — bars show percent remaining // (so a fresh account renders full, a depleted one renders empty), not diff --git a/mobile/src/components/account-usage-state.test.ts b/mobile/src/components/account-usage-state.test.ts new file mode 100644 index 00000000000..f567143584e --- /dev/null +++ b/mobile/src/components/account-usage-state.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' + +import { + getInactiveProviderUsage, + getUsageBarState, + hasActiveProviderUsage, + hasRenderableUsage, + type AccountsSnapshot, + type InactiveAccountUsage, + type ProviderRateLimits +} from './account-usage-state' + +function makeLimits(overrides: Partial = {}): ProviderRateLimits { + return { + provider: 'claude', + session: null, + weekly: null, + monthly: null, + updatedAt: 0, + error: null, + status: 'idle', + ...overrides + } +} + +function makeSnapshot( + overrides: { + claudeLimits?: ProviderRateLimits | null + codexLimits?: ProviderRateLimits | null + claudeAccounts?: AccountsSnapshot['claude']['accounts'] + codexAccounts?: AccountsSnapshot['codex']['accounts'] + inactiveClaudeAccounts?: InactiveAccountUsage[] + inactiveCodexAccounts?: InactiveAccountUsage[] + } = {} +): AccountsSnapshot { + return { + claude: { accounts: overrides.claudeAccounts ?? [], activeAccountId: null }, + codex: { accounts: overrides.codexAccounts ?? [], activeAccountId: null }, + rateLimits: { + claude: overrides.claudeLimits ?? null, + codex: overrides.codexLimits ?? null, + inactiveClaudeAccounts: overrides.inactiveClaudeAccounts ?? [], + inactiveCodexAccounts: overrides.inactiveCodexAccounts ?? [] + } + } +} + +describe('hasActiveProviderUsage', () => { + it('is false when there are no rate limits at all', () => { + expect(hasActiveProviderUsage(null)).toBe(false) + }) + + it('is true when a session window has data', () => { + expect( + hasActiveProviderUsage( + makeLimits({ + status: 'ok', + session: { usedPercent: 12, windowMinutes: 300, resetsAt: null, resetDescription: null } + }) + ) + ).toBe(true) + }) + + it('is true when a successful fetch returned ok even with empty windows', () => { + expect(hasActiveProviderUsage(makeLimits({ status: 'ok' }))).toBe(true) + }) + + it('is false for an unavailable/error provider with no window data (no creds)', () => { + expect(hasActiveProviderUsage(makeLimits({ status: 'unavailable' }))).toBe(false) + expect(hasActiveProviderUsage(makeLimits({ status: 'error', error: 'nope' }))).toBe(false) + }) +}) + +describe('hasRenderableUsage', () => { + it('is true when the provider has at least one managed account', () => { + const snapshot = makeSnapshot({ + claudeAccounts: [{ id: 'a', email: 'x@y.z' }] + }) + expect(hasRenderableUsage(snapshot, 'claude')).toBe(true) + }) + + // The bug: system-default auth has zero managed accounts but real usage data, + // and the home screen used to hide it entirely. + it('is true with zero managed accounts when active rate-limit data exists (system default)', () => { + const snapshot = makeSnapshot({ + codexLimits: makeLimits({ + provider: 'codex', + status: 'ok', + session: { usedPercent: 40, windowMinutes: 300, resetsAt: null, resetDescription: null } + }) + }) + expect(hasRenderableUsage(snapshot, 'codex')).toBe(true) + }) + + it('is false with zero accounts and no usable rate-limit data', () => { + const snapshot = makeSnapshot({ + claudeLimits: makeLimits({ status: 'unavailable' }) + }) + expect(hasRenderableUsage(snapshot, 'claude')).toBe(false) + expect(hasRenderableUsage(makeSnapshot(), 'claude')).toBe(false) + }) +}) + +describe('getInactiveProviderUsage', () => { + it('returns inactive usage using the runtime rateLimits payload shape', () => { + const limits = makeLimits({ + status: 'ok', + session: { usedPercent: 52, windowMinutes: 300, resetsAt: null, resetDescription: null } + }) + const snapshot = makeSnapshot({ + inactiveClaudeAccounts: [ + { accountId: 'account-1', rateLimits: limits, updatedAt: 123, isFetching: false } + ] + }) + + expect(getInactiveProviderUsage(snapshot, 'claude', 'account-1')?.rateLimits).toBe(limits) + }) +}) + +describe('getUsageBarState', () => { + it('keeps stale window data visible during a transient error', () => { + const bar = getUsageBarState( + makeLimits({ + status: 'error', + error: 'temporarily unavailable', + session: { usedPercent: 72, windowMinutes: 300, resetsAt: null, resetDescription: null } + }), + 'session' + ) + + expect(bar).toEqual({ usedPercent: 72, unavailable: false, loading: false }) + }) + + it('shows loading for a fetching provider without a window', () => { + expect(getUsageBarState(makeLimits({ status: 'fetching' }), 'weekly')).toEqual({ + usedPercent: null, + unavailable: false, + loading: true + }) + }) +}) diff --git a/mobile/src/components/account-usage-state.ts b/mobile/src/components/account-usage-state.ts new file mode 100644 index 00000000000..c2bee731cd0 --- /dev/null +++ b/mobile/src/components/account-usage-state.ts @@ -0,0 +1,129 @@ +// Why: keep these shapes in lockstep with src/shared/types.ts and +// src/shared/rate-limit-types.ts. We don't import from desktop here because +// the mobile bundle must not pull in Electron-coupled type files. +// +// Pure state/selectors live here (no React Native imports) so they can be +// unit-tested directly; AccountUsage.tsx re-exports them alongside the +// UsageBar component. +export type RateLimitWindow = { + usedPercent: number + windowMinutes: number + resetsAt: number | null + resetDescription: string | null +} + +export type ProviderRateLimits = { + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' + session: RateLimitWindow | null + weekly: RateLimitWindow | null + monthly?: RateLimitWindow | null + buckets?: Array + updatedAt: number + error: string | null + status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable' +} + +export type InactiveAccountUsage = { + accountId: string + rateLimits: ProviderRateLimits | null + updatedAt: number + isFetching: boolean +} + +export type ClaudeAccountSummary = { + id: string + email: string + organizationName?: string | null +} + +export type CodexAccountSummary = { + id: string + email: string + workspaceLabel?: string | null +} + +export type AccountsSnapshot = { + claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null } + codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null } + rateLimits: { + claude: ProviderRateLimits | null + codex: ProviderRateLimits | null + inactiveClaudeAccounts: InactiveAccountUsage[] + inactiveCodexAccounts: InactiveAccountUsage[] + } +} + +export type ProviderKey = 'claude' | 'codex' + +export type UsageBarState = { + usedPercent: number | null + unavailable: boolean + loading: boolean +} + +export function getActiveProviderRateLimits( + snapshot: AccountsSnapshot, + provider: ProviderKey +): ProviderRateLimits | null { + return provider === 'claude' ? snapshot.rateLimits.claude : snapshot.rateLimits.codex +} + +export function getInactiveProviderUsage( + snapshot: AccountsSnapshot, + provider: ProviderKey, + accountId: string +): InactiveAccountUsage | null { + const list = + provider === 'claude' + ? snapshot.rateLimits.inactiveClaudeAccounts + : snapshot.rateLimits.inactiveCodexAccounts + return list.find((u) => u.accountId === accountId) ?? null +} + +// Why: rate limits are fetched for the active target even when no Orca-managed +// account exists (the default target is the agent's own system-default login). +// Treat a provider as having usage worth showing when a fetch succeeded or any +// window has data; an unavailable/error provider with no windows means the +// system-default login has no credentials for it, so there is nothing to show. +export function hasActiveProviderUsage(limits: ProviderRateLimits | null): boolean { + if (!limits) { + return false + } + if ( + limits.session != null || + limits.weekly != null || + limits.monthly != null || + (limits.buckets && limits.buckets.length > 0) + ) { + return true + } + return limits.status === 'ok' +} + +// Why: transient errors keep the last successful window data, so availability +// is per window rather than per provider status. +export function getUsageBarState( + limits: ProviderRateLimits | null, + windowKey: 'session' | 'weekly', + isFetchingOverride?: boolean +): UsageBarState { + const window = limits?.[windowKey] ?? null + const fetching = + isFetchingOverride ?? (limits?.status === 'fetching' || limits?.status === 'idle') + return { + usedPercent: window?.usedPercent ?? null, + unavailable: window == null && !fetching, + loading: fetching && window == null + } +} + +// Why: the usage UI must render for the system-default login, not only for +// Orca-managed accounts. Show a provider when it has at least one managed +// account OR active rate-limit data for the system-default target. +export function hasRenderableUsage(snapshot: AccountsSnapshot, provider: ProviderKey): boolean { + const accounts = provider === 'claude' ? snapshot.claude.accounts : snapshot.codex.accounts + if (accounts.length > 0) { + return true + } + return hasActiveProviderUsage(getActiveProviderRateLimits(snapshot, provider)) +}