Fix account switcher state

This commit is contained in:
Jinjing
2026-11-12 22:52:41 -08:00
parent 9103a362db
commit a995ce3f03
20 changed files with 1023 additions and 81 deletions
+2 -2
View File
@@ -139,13 +139,13 @@
1 src/renderer/src/components/skills/setup/linear-agent-skill-install-cta.tsx
1 src/renderer/src/components/source-control/BaseRefPicker.tsx
1 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx
1 src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx
2 src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx
1 src/renderer/src/components/status-bar/SshStatusSegment.tsx
1 src/renderer/src/components/status-bar/StatusBarSurface.tsx
2 src/renderer/src/components/status-bar/provider-account-sync-key.ts
1 src/renderer/src/components/status-bar/usage-provider-settings-target.ts
1 src/renderer/src/components/status-bar/use-claude-switcher-state.ts
2 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts
1 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts
1 src/renderer/src/components/status-bar/use-status-bar-controller.ts
1 src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts
1 src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts
@@ -0,0 +1,649 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import type * as ProviderAccountsClient from '@/runtime/provider-accounts/observations/runtime-provider-accounts-client'
import type * as CodexSessionRestart from '@/lib/agent-session/accounts/codex/codex-session-restart'
import { afterEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { getDefaultSettings } from '../../../../shared/constants'
import type { CodexRateLimitAccountsState } from '../../../../shared/managed-account-types'
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
import type { ProviderAccountsSnapshot } from '@/runtime/provider-accounts/observations/runtime-provider-accounts-client'
import { toast } from 'sonner'
import { markLiveCodexSessionsForRestart } from '@/lib/agent-session/accounts/codex/codex-session-restart'
import { CodexSwitcherMenu } from './CodexSwitcherMenu'
const mocks = vi.hoisted(() => ({ load: vi.fn(), select: vi.fn(), reauthenticate: vi.fn() }))
vi.mock(
'@/runtime/provider-accounts/observations/runtime-provider-accounts-client',
async (original) => ({
...(await original<typeof ProviderAccountsClient>()),
fetchProviderAccountsSnapshot: mocks.load,
selectCodexProviderAccount: mocks.select
})
)
vi.mock('@/lib/agent-session/accounts/codex/codex-session-restart', async (original) => ({
...(await original<typeof CodexSessionRestart>()),
markLiveCodexSessionsForRestart: vi.fn()
}))
vi.mock('@/runtime/hosts/capabilities/windows-terminal-capabilities', () => ({
useWindowsTerminalCapabilities: () => ({ wslDistros: [], isLoading: false }),
getWindowsTerminalCapabilityOwnerKey: () => 'local'
}))
vi.mock('./ProviderDetailsMenu', () => ({
ProviderDetailsMenu: ({
children,
open,
onOpenChange
}: {
children: ReactNode
open: boolean
onOpenChange: (open: boolean) => void
}) => (
<div>
<button aria-expanded={open} onClick={() => onOpenChange(!open)}>
Toggle details
</button>
{children}
</div>
)
}))
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenuLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => null,
DropdownMenuItem: ({
children,
disabled,
onSelect
}: {
children: ReactNode
disabled?: boolean
onSelect: (event: { preventDefault: () => void }) => void
}) => (
<div
role="menuitem"
aria-disabled={!!disabled}
onClick={() => {
if (!disabled) {
onSelect({ preventDefault: () => {} })
}
}}
>
{children}
</div>
)
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
const initial = useAppStore.getState()
afterEach(() => {
cleanup()
useAppStore.setState(initial, true)
vi.restoreAllMocks()
vi.resetAllMocks()
})
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: Error) => void
const promise = new Promise<T>((done, fail) => {
resolve = done
reject = fail
})
return { promise, resolve, reject }
}
function accounts(owner: string): CodexRateLimitAccountsState {
return {
accounts: [owner, `${owner}-other`].map((id) => ({
id,
email: `${id}@example.com`,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
})),
activeAccountId: owner
}
}
function snapshot(owner: string): ProviderAccountsSnapshot {
return {
codex: accounts(owner),
claude: { accounts: [], activeAccountId: null },
rateLimits: null
}
}
it.each(['before', 'after'] as const)(
'refreshes a returned owner when its old roster resolves %s its retired selection',
async (order) => {
const pending = deferred<CodexRateLimitAccountsState>()
const returnedRoster = deferred<ProviderAccountsSnapshot>()
const selected = { ...accounts('remote-a'), activeAccountId: 'remote-a-other' }
mocks.load
.mockResolvedValueOnce(snapshot('remote-a'))
.mockResolvedValueOnce(snapshot('remote-b'))
.mockReturnValueOnce(returnedRoster.promise)
.mockResolvedValueOnce({ ...snapshot('remote-a'), codex: selected })
mocks.select.mockReturnValueOnce(pending.promise)
configureOwner('remote-a')
render(<CodexSwitcherMenu codex={usage} compact iconOnly />)
await act(async () => {})
await expand('remote-a')
fireEvent.click(row('remote-a'))
await act(async () =>
useAppStore.setState((state) => ({
settings: { ...state.settings!, activeRuntimeEnvironmentId: 'remote-b' }
}))
)
await act(async () =>
useAppStore.setState((state) => ({
settings: { ...state.settings!, activeRuntimeEnvironmentId: 'remote-a' }
}))
)
if (order === 'before') {
await act(async () => returnedRoster.resolve(snapshot('remote-a')))
await expand('remote-a')
expect(row('remote-a').getAttribute('aria-disabled')).toBe('false')
}
await act(async () => pending.resolve(selected))
if (order === 'after') {
await act(async () => returnedRoster.resolve(snapshot('remote-a')))
await expand('remote-a')
}
expect(row('remote-a').getAttribute('aria-disabled')).toBe('true')
const previousAccountRow = screen
.getByText('remote-a@example.com')
.closest('[role="menuitem"]')!
expect(previousAccountRow.getAttribute('aria-disabled')).toBe('false')
mocks.select.mockResolvedValueOnce(accounts('remote-a'))
fireEvent.click(previousAccountRow)
await act(async () => {})
expect(mocks.select).toHaveBeenLastCalledWith(
{ kind: 'environment', environmentId: 'remote-a' },
{ accountId: 'remote-a', runtime: 'host', wslDistro: null }
)
expect(previousAccountRow.getAttribute('aria-disabled')).toBe('true')
expect(row('remote-a').getAttribute('aria-disabled')).toBe('false')
}
)
const usage: ProviderRateLimits = {
provider: 'codex',
session: null,
weekly: null,
status: 'ok',
error: null,
updatedAt: 1
}
function configureOwner(environmentId: string | null) {
useAppStore.setState({
settings: {
...getDefaultSettings('/test'),
activeRuntimeEnvironmentId: environmentId,
codexManagedAccounts: accounts('local').accounts.map((account) => ({
...account,
managedHomePath: '/unused'
})),
activeCodexManagedAccountId: 'local'
},
fetchSettings: vi.fn(async () => {}),
fetchInactiveCodexAccountUsage: vi.fn(async () => {})
})
}
function row(owner: string) {
return screen.getByText(`${owner}-other@example.com`).closest('[role="menuitem"]')!
}
async function expand(owner: string) {
if (screen.queryByText(`${owner}-other@example.com`)) {
return
}
fireEvent.click(screen.getAllByText(`${owner}@example.com`)[0])
await act(async () => {})
}
it.each([
['select', null],
['list', 'remote-b'],
['select', 'remote-b']
] as const)(
'retires the old roster and pending %s when moving to %s',
async (operation, nextEnvironmentId) => {
const pending = deferred<ProviderAccountsSnapshot>()
const replacement = deferred<ProviderAccountsSnapshot>()
mocks.load.mockResolvedValueOnce(snapshot('remote-a'))
configureOwner('remote-a')
render(<CodexSwitcherMenu codex={usage} compact iconOnly />)
await act(async () => {})
await expand('remote-a')
if (operation === 'list') {
mocks.load.mockReturnValueOnce(pending.promise)
await act(async () =>
useAppStore.setState((state) => ({
settings: {
...state.settings!,
codexManagedAccounts: state.settings!.codexManagedAccounts.map((account) => ({
...account,
updatedAt: 2
}))
}
}))
)
} else {
mocks.select.mockImplementationOnce(async () => (await pending.promise).codex)
fireEvent.click(row('remote-a'))
expect(row('remote-a').getAttribute('aria-disabled')).toBe('true')
}
mocks.load.mockReturnValueOnce(replacement.promise)
await act(async () =>
useAppStore.setState((state) => ({
settings: { ...state.settings!, activeRuntimeEnvironmentId: nextEnvironmentId }
}))
)
expect(screen.queryAllByText('remote-a@example.com')).toHaveLength(0)
const nextOwner = nextEnvironmentId ?? 'local'
await act(async () => replacement.resolve(snapshot(nextOwner)))
await expand(nextOwner)
expect(row(nextOwner).getAttribute('aria-disabled')).toBe('false')
await act(async () => pending.resolve(snapshot('stale-owner')))
expect(screen.queryByText('stale-owner@example.com')).toBeNull()
expect(row(nextOwner).getAttribute('aria-disabled')).toBe('false')
if (operation === 'select') {
expect(markLiveCodexSessionsForRestart).toHaveBeenCalledWith(
expect.objectContaining({
owner: { kind: 'environment', environmentId: 'remote-a' },
target: { runtime: 'host', wslDistro: null }
})
)
}
mocks.select.mockResolvedValueOnce(accounts(nextOwner))
fireEvent.click(row(nextOwner))
await act(async () => {})
expect(mocks.select).toHaveBeenLastCalledWith(
nextEnvironmentId === null
? { kind: 'local' }
: { kind: 'environment', environmentId: nextEnvironmentId },
{ accountId: `${nextOwner}-other`, runtime: 'host', wslDistro: null }
)
}
)
it.each(['resolve', 'reject'] as const)(
'retires local sign-in busy state across an owner change and late %s',
async (settlement) => {
const pending = deferred<CodexRateLimitAccountsState>()
mocks.load.mockResolvedValueOnce(snapshot('local')).mockResolvedValueOnce(snapshot('remote-b'))
mocks.reauthenticate.mockReturnValueOnce(pending.promise)
Object.defineProperty(window, 'api', {
configurable: true,
value: {
...window.api,
codexAccounts: { ...window.api?.codexAccounts, reauthenticate: mocks.reauthenticate }
}
})
configureOwner(null)
useAppStore.setState((state) => ({
rateLimits: {
...state.rateLimits,
inactiveCodexAccounts: [
{
accountId: 'local-other',
updatedAt: 1,
isFetching: false,
rateLimits: { ...usage, status: 'error', error: 'Not signed in' }
}
]
}
}))
render(<CodexSwitcherMenu codex={usage} compact iconOnly />)
await act(async () => {})
await expand('local')
fireEvent.click(screen.getByRole('button', { name: /Sign in/ }))
expect(row('local').getAttribute('aria-disabled')).toBe('true')
await act(async () =>
useAppStore.setState((state) => ({
settings: { ...state.settings!, activeRuntimeEnvironmentId: 'remote-b' }
}))
)
await expand('remote-b')
expect(row('remote-b').getAttribute('aria-disabled')).toBe('false')
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await act(async () => {
if (settlement === 'resolve') {
pending.resolve(accounts('stale-owner'))
} else {
pending.reject(new Error('Fixture sign-in failed'))
}
})
expect(screen.queryByText('stale-owner@example.com')).toBeNull()
expect(row('remote-b').getAttribute('aria-disabled')).toBe('false')
if (settlement === 'resolve') {
expect(toast.success).toHaveBeenCalled()
expect(toast.error).not.toHaveBeenCalled()
} else {
// A sign-in that fails after the user moved to another host is still reported.
expect(toast.error).toHaveBeenCalled()
expect(toast.success).not.toHaveBeenCalled()
}
if (settlement === 'resolve') {
expect(markLiveCodexSessionsForRestart).toHaveBeenCalledWith(
expect.objectContaining({
owner: { kind: 'local' },
target: { runtime: 'host', wslDistro: null }
})
)
}
mocks.select.mockResolvedValueOnce(accounts('remote-b'))
fireEvent.click(row('remote-b'))
await act(async () => {})
expect(mocks.select).toHaveBeenLastCalledWith(
{ kind: 'environment', environmentId: 'remote-b' },
{ accountId: 'remote-b-other', runtime: 'host', wslDistro: null }
)
errorSpy.mockRestore()
}
)
it('keeps a completed selection when an older same-owner list resolves later', async () => {
const pending = deferred<ProviderAccountsSnapshot>()
mocks.load.mockResolvedValueOnce(snapshot('remote-a')).mockReturnValueOnce(pending.promise)
configureOwner('remote-a')
render(<CodexSwitcherMenu codex={usage} compact iconOnly />)
await act(async () => {})
await act(async () =>
useAppStore.setState((state) => ({
settings: { ...state.settings!, activeCodexManagedAccountId: 'local-other' }
}))
)
mocks.select.mockResolvedValueOnce({ ...accounts('remote-a'), activeAccountId: 'remote-a-other' })
fireEvent.click(row('remote-a'))
await act(async () => {})
expect(row('remote-a').getAttribute('aria-disabled')).toBe('true')
await act(async () => pending.resolve(snapshot('remote-a')))
expect(row('remote-a').getAttribute('aria-disabled')).toBe('true')
expect(
screen
.getByText('remote-a@example.com')
.closest('[role="menuitem"]')
?.getAttribute('aria-disabled')
).toBe('false')
})
it('retires busy state when moving between WSL distros with the same local account target', async () => {
const pending = deferred<CodexRateLimitAccountsState>()
const wslAccounts: CodexRateLimitAccountsState = {
accounts: ['Ubuntu', 'Debian'].flatMap((distro) =>
accounts(distro).accounts.map((account) => ({
...account,
managedHomeRuntime: 'wsl' as const,
wslDistro: distro
}))
),
activeAccountId: null,
activeAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'Ubuntu', Debian: 'Debian' } }
}
mocks.load.mockResolvedValue({ ...snapshot('local'), codex: wslAccounts })
mocks.select.mockReturnValueOnce(pending.promise)
configureOwner('wsl-ubuntu')
useAppStore.setState((state) => ({
settings: {
...state.settings!,
codexManagedAccounts: wslAccounts.accounts.map((account) => ({
...account,
managedHomePath: '/unused'
})),
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: wslAccounts.activeAccountIdsByRuntime
},
rateLimits: { ...state.rateLimits, codexTarget: { runtime: 'wsl', wslDistro: 'Ubuntu' } },
runtimeEnvironments: ['Ubuntu', 'Debian'].map((distro) => ({
id: `wsl-${distro.toLowerCase()}`,
name: distro,
createdAt: 1,
updatedAt: 1,
lastUsedAt: null,
runtimeId: null,
endpoints: [
{ id: 'local', kind: 'websocket', label: 'Local bridge', endpoint: 'ws://127.0.0.1:7001' }
],
preferredEndpointId: 'local',
orcadDeployment: { kind: 'wsl', distro, localPort: 7001, remotePort: 6768 }
}))
}))
render(<CodexSwitcherMenu codex={usage} compact iconOnly />)
await act(async () => {})
fireEvent.click(screen.getByRole('button', { name: 'Toggle details' }))
fireEvent.click(row('Ubuntu'))
expect(row('Ubuntu').getAttribute('aria-disabled')).toBe('true')
await act(async () =>
useAppStore.setState((state) => ({
settings: { ...state.settings!, activeRuntimeEnvironmentId: 'wsl-debian' },
rateLimits: { ...state.rateLimits, codexTarget: { runtime: 'wsl', wslDistro: 'Debian' } }
}))
)
expect(screen.getByRole('button', { name: 'Toggle details' }).getAttribute('aria-expanded')).toBe(
'false'
)
expect(row('Debian').getAttribute('aria-disabled')).toBe('false')
await act(async () => pending.resolve(wslAccounts))
expect(row('Debian').getAttribute('aria-disabled')).toBe('false')
mocks.select.mockResolvedValueOnce(wslAccounts)
fireEvent.click(row('Debian'))
await act(async () => {})
expect(mocks.select).toHaveBeenNthCalledWith(
1,
{ kind: 'local' },
{ accountId: 'Ubuntu-other', runtime: 'wsl', wslDistro: 'Ubuntu' }
)
expect(mocks.select).toHaveBeenNthCalledWith(
2,
{ kind: 'local' },
{ accountId: 'Debian-other', runtime: 'wsl', wslDistro: 'Debian' }
)
})
// One remote host, "r", whose roster names which account the host reports as active.
function roster(activeAccountId: string): CodexRateLimitAccountsState {
return {
accounts: ['r', 'r-a', 'r-b'].map((id) => ({
id,
email: `${id}@example.com`,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
})),
activeAccountId
}
}
const hostReports = (active: string): ProviderAccountsSnapshot => ({
...snapshot('unused'),
codex: roster(active)
})
const item = (id: string) => screen.getByText(`${id}@example.com`).closest('[role="menuitem"]')!
const isActive = (id: string) => item(id).getAttribute('aria-disabled') === 'true'
async function show() {
if (!screen.queryByText('r-a@example.com')) {
fireEvent.click(screen.getAllByText('r@example.com')[0])
await act(async () => {})
}
}
const focusHost = (environmentId: string) =>
act(async () =>
useAppStore.setState((state) => ({
settings: { ...state.settings!, activeRuntimeEnvironmentId: environmentId }
}))
)
async function reopenMenu() {
fireEvent.click(screen.getByRole('button', { name: 'Toggle details' }))
await act(async () => {})
}
// The remote host's own state: reads return whatever it reports at the time they are made.
function serveHost() {
const host = { active: 'r', reachable: true }
mocks.load.mockImplementation(async (target: { kind: string; environmentId?: string }) => {
if (target.environmentId === 'remote-b') {
return snapshot('remote-b')
}
if (!host.reachable) {
throw new Error('host unreachable')
}
return hostReports(host.active)
})
vi.spyOn(console, 'error').mockImplementation(() => {})
return host
}
it('shows a late-applied selection on reopen even after an older selection succeeded', async () => {
const host = serveHost()
const older = deferred<CodexRateLimitAccountsState>()
const newer = deferred<CodexRateLimitAccountsState>()
mocks.select.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise)
configureOwner('ordering-host')
render(<CodexSwitcherMenu codex={usage} compact iconOnly />)
await act(async () => {})
await show()
fireEvent.click(item('r-a'))
await focusHost('remote-b')
await focusHost('ordering-host')
await show()
fireEvent.click(item('r-b'))
await act(async () => newer.reject(new Error('timed out')))
host.active = 'r-a'
await act(async () => older.resolve(roster('r-a')))
await show()
expect(isActive('r-a')).toBe(true)
// The newer selection applies on the host only now; the older success must not have settled it.
host.active = 'r-b'
await reopenMenu()
await show()
expect(isActive('r-b')).toBe(true)
expect(mocks.select).toHaveBeenCalledTimes(2)
})
it('keeps the list after a rejected selection whose re-read fails, then shows the host account on reopen', async () => {
const host = serveHost()
const selection = deferred<CodexRateLimitAccountsState>()
const reread = deferred<ProviderAccountsSnapshot>()
mocks.select.mockReturnValueOnce(selection.promise)
configureOwner('rejecting-host')
render(<CodexSwitcherMenu codex={usage} compact iconOnly />)
await act(async () => {})
await show()
fireEvent.click(item('r-a'))
await focusHost('remote-b')
await focusHost('rejecting-host')
host.reachable = false
await act(async () => selection.reject(new Error('timed out')))
await show()
expect(isActive('r')).toBe(true)
mocks.load.mockReturnValueOnce(reread.promise)
await reopenMenu()
// The previous list stays on screen while the re-read runs.
expect(isActive('r')).toBe(true)
expect(isActive('r-a')).toBe(false)
await act(async () => reread.resolve(hostReports('r-a')))
expect(isActive('r-a')).toBe(true)
expect(mocks.select).toHaveBeenCalledTimes(1)
})
it.each(['reachable', 'unreachable'] as const)(
'shows a late-applied selection at the first open of a menu shown again while the host was %s',
async (whileAway) => {
// Uncertainty outlives a test, so each case uses its own host.
const hostId = `returning-host-${whileAway}`
const host = serveHost()
mocks.select.mockRejectedValueOnce(new Error('timed out'))
configureOwner(hostId)
render(<CodexSwitcherMenu codex={usage} compact iconOnly />)
await act(async () => {})
await show()
fireEvent.click(item('r-a'))
await act(async () => {})
await focusHost('remote-b')
host.reachable = whileAway === 'reachable'
await focusHost(hostId)
// The selection applies on the host only now, well after the menu was shown again.
host.reachable = true
host.active = 'r-a'
await reopenMenu()
await show()
expect(isActive('r-a')).toBe(true)
expect(mocks.select).toHaveBeenCalledTimes(1)
}
)
it.each(['answers', 'fails', 'is overtaken by a new selection'] as const)(
'keeps the last known list on screen at an uncertain host menu open whose read %s',
async (read) => {
const hostId = `last-known-host-${read.split(' ')[0]}`
const host = serveHost()
mocks.select.mockRejectedValueOnce(new Error('timed out'))
configureOwner(hostId)
render(<CodexSwitcherMenu codex={usage} compact iconOnly />)
await act(async () => {})
await show()
fireEvent.click(item('r-a'))
await act(async () => {})
await focusHost('remote-b')
// The rejected selection applies on the host while the user is away, so only a read can show it.
host.active = 'r-a'
await focusHost(hostId)
const openRead = deferred<ProviderAccountsSnapshot>()
mocks.load.mockReturnValueOnce(openRead.promise)
await reopenMenu()
await show()
expect(isActive('r')).toBe(true)
if (read === 'answers') {
await act(async () => openRead.resolve(hostReports('r-a')))
expect(isActive('r-a')).toBe(true)
expect(mocks.select).toHaveBeenCalledTimes(1)
} else if (read === 'fails') {
await act(async () => openRead.reject(new Error('host unreachable')))
expect(isActive('r')).toBe(true)
expect(mocks.select).toHaveBeenCalledTimes(1)
} else {
mocks.select.mockResolvedValueOnce(roster('r-b'))
fireEvent.click(item('r-b'))
await act(async () => {})
expect(isActive('r-b')).toBe(true)
// The open read was issued before that selection, so its answer must not replace the result.
await act(async () => openRead.resolve(hostReports('r-a')))
expect(isActive('r-b')).toBe(true)
expect(mocks.select).toHaveBeenCalledTimes(2)
}
}
)
it('prepares fresh uncertain accounts at Usage open, then refreshes a later submenu expansion', async () => {
const host = serveHost()
mocks.select.mockRejectedValueOnce(new Error('timed out'))
configureOwner('parent-open-host')
const menu = render(<CodexSwitcherMenu codex={usage} compact iconOnly />)
await act(async () => {})
await show()
fireEvent.click(item('r-a'))
await act(async () => {})
menu.unmount()
// Usage opens after the rejected selection finally applies on the host.
host.active = 'r-a'
const fresh = deferred<ProviderAccountsSnapshot>()
mocks.load.mockReturnValueOnce(fresh.promise)
render(<CodexSwitcherMenu codex={usage} compact iconOnly parentMenuOpen />)
await act(async () => fresh.resolve(hostReports('r-a')))
expect(isActive('r-a')).toBe(true)
// Expanding Codex consumes that prepared answer rather than replacing it with another read.
host.active = 'r-b'
await reopenMenu()
expect(isActive('r-a')).toBe(true)
await reopenMenu()
await reopenMenu()
expect(isActive('r-b')).toBe(true)
expect(mocks.select).toHaveBeenCalledTimes(1)
})
@@ -14,20 +14,42 @@ import { ProviderDetailsMenu } from './ProviderDetailsMenu'
import { AccountSwitchSection } from './AccountSwitchList'
import { AccountSwitchRowItem } from './AccountSwitchRowItem'
import { useCodexSwitcherController } from './use-codex-switcher-controller'
import { useAppStore } from '../../store'
import { defaultScopeHost } from '@/runtime/hosts/selection/default-creation-host'
import { useActiveAccountOwner } from '@/runtime/provider-accounts/ownership/use-active-account-owner'
import type { AccountOwner } from '@/runtime/provider-accounts/ownership/wsl-host-account-owner'
import { getProviderAccountsOwnerKey } from '@/runtime/provider-accounts/observations/runtime-provider-accounts-client'
export function CodexSwitcherMenu({
codex,
compact,
iconOnly,
asSubmenu = false,
triggerContent
}: {
type CodexSwitcherMenuProps = {
codex: ProviderRateLimits
compact: boolean
iconOnly: boolean
asSubmenu?: boolean
parentMenuOpen?: boolean
triggerContent?: React.ReactNode
}): React.JSX.Element {
}
export function CodexSwitcherMenu(props: CodexSwitcherMenuProps): React.JSX.Element {
const settings = useAppStore((state) => state.settings)
const owner = useActiveAccountOwner(defaultScopeHost(settings))
return (
<CodexSwitcherForOwner
{...props}
key={`${getProviderAccountsOwnerKey(owner.target)}:${owner.wslHostDistro ?? ''}`}
owner={owner}
/>
)
}
function CodexSwitcherForOwner({
codex,
compact,
iconOnly,
asSubmenu = false,
triggerContent,
parentMenuOpen,
owner
}: CodexSwitcherMenuProps & { owner: AccountOwner }): React.JSX.Element {
const t = useTranslate()
const {
activeTarget,
@@ -55,7 +77,7 @@ export function CodexSwitcherMenu({
setSkipFutureResetConfirm,
skipFutureResetConfirm,
switchGroups
} = useCodexSwitcherController(codex)
} = useCodexSwitcherController(codex, owner, parentMenuOpen)
const isBusy = isSwitching || reauthenticatingAccountId !== null
const rows = (selectedGroup?.targets ?? []).map((target) => {
@@ -251,6 +251,7 @@ export function StatusBarSurface({
return (
<CodexSwitcherMenu
codex={p}
parentMenuOpen={usageMenuOpen}
compact={compact}
iconOnly={false}
asSubmenu
@@ -7,8 +7,13 @@ import {
markLiveCodexSessionsForRestart,
resolveCodexRestartPromptAccountLabel
} from '@/lib/agent-session/accounts/codex/codex-session-restart'
import { getRuntimeSentenceLabel } from '@/lib/provider-accounts/account-runtime-label'
import { refreshProviderAccountLists } from './use-provider-account-list'
import { getCodexStatusActiveId } from './status-bar-codex-accounts'
import type { CodexStatusRuntimeTarget } from './status-bar-runtime-targets'
import {
getCodexStatusRuntimeLabel,
type CodexStatusRuntimeTarget
} from './status-bar-runtime-targets'
type CodexSignInActionDependencies = {
accountState: CodexRateLimitAccountsState
@@ -19,7 +24,7 @@ type CodexSignInActionDependencies = {
mountedRef: MutableRefObject<boolean>
reauthenticatingAccountId: string | null
recordFeatureInteraction: AppState['recordFeatureInteraction']
setAccounts: Dispatch<SetStateAction<CodexRateLimitAccountsState>>
setAccounts: (accounts: CodexRateLimitAccountsState) => void
setReauthenticatingAccountId: Dispatch<SetStateAction<string | null>>
}
@@ -44,6 +49,12 @@ export async function signInCodexAccount(
return
}
const previousActiveAccountId = getCodexStatusActiveId(accountState, target)
// The result can land after the user moved to another host, so the toast names its runtime.
const runtimeLabel = getRuntimeSentenceLabel({
runtime: target.runtime,
// The same name the runtime's tab shows.
label: getCodexStatusRuntimeLabel(target)
})
setReauthenticatingAccountId(accountId)
try {
const next = await window.api.codexAccounts.reauthenticate({
@@ -75,14 +86,22 @@ export async function signInCodexAccount(
await fetchInactiveCodexAccountUsage()
}
toast.success(
translate('auto.components.status.bar.StatusBar.codexSignInSuccess', 'Signed in to Codex')
translate(
'auto.components.status.bar.StatusBar.codexSignInSuccessOnRuntime',
'Signed in to Codex on {{runtime}}',
{ runtime: runtimeLabel }
)
)
} catch (error) {
console.error('Failed to re-authenticate Codex account from status bar:', error)
// Why: a failed sign-in may still have changed the stored login.
refreshProviderAccountLists({ kind: 'local' }, 'codex', 'unknown')
await fetchSettings().catch(() => {})
toast.error(
translate(
'auto.components.status.bar.StatusBar.codexSignInError',
'Codex sign-in failed. Please try again.'
'auto.components.status.bar.StatusBar.codexSignInFailedOnRuntime',
'Codex sign-in failed on {{runtime}}.',
{ runtime: runtimeLabel }
)
)
} finally {
@@ -1,12 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { CodexRateLimitAccountsState } from '../../../../shared/managed-account-types'
import type { ProviderRateLimits } from '../../../../shared/rate-limit-types'
import { useAppStore } from '../../store'
import { defaultScopeHost } from '@/runtime/hosts/selection/default-creation-host'
import { useActiveAccountOwner } from '@/runtime/provider-accounts/ownership/use-active-account-owner'
import type { AccountOwner } from '@/runtime/provider-accounts/ownership/wsl-host-account-owner'
import { runtimeTargetEnvironmentId } from '@/runtime/rpc/targets/runtime-client-target'
import {
fetchProviderAccountsSnapshot,
emptyCodexAccountsState,
selectCodexProviderAccount
} from '@/runtime/provider-accounts/observations/runtime-provider-accounts-client'
import { useTranslate } from '@/i18n/use-translate'
@@ -34,20 +32,20 @@ import {
resolveCodexStatusAccountState
} from './status-bar-codex-accounts'
import { signInCodexAccount } from './codex-sign-in-action'
import { refreshProviderAccountLists, useProviderAccountList } from './use-provider-account-list'
export function useCodexSwitcherController(codex: ProviderRateLimits) {
export function useCodexSwitcherController(
codex: ProviderRateLimits,
owner: AccountOwner,
parentMenuOpen = false
) {
const t = useTranslate()
const [open, setOpen] = useState(false)
const [resetConfirmOpen, setResetConfirmOpen] = useState(false)
const [skipFutureResetConfirm, setSkipFutureResetConfirm] = useState(false)
const [accounts, setAccounts] = useState<CodexRateLimitAccountsState>({
accounts: [],
activeAccountId: null
})
const [isSwitching, setIsSwitching] = useState(false)
const [isRedeemingReset, setIsRedeemingReset] = useState(false)
const [reauthenticatingAccountId, setReauthenticatingAccountId] = useState<string | null>(null)
const mountedRef = useRef(true)
const menuOpenRef = useRef(open)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
@@ -61,7 +59,7 @@ export function useCodexSwitcherController(codex: ProviderRateLimits) {
const codexTarget = useAppStore((s) => s.rateLimits.codexTarget)
const settings = useAppStore((s) => s.settings)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const { target: runtimeTarget, wslHostDistro } = useActiveAccountOwner(defaultScopeHost(settings))
const { target: runtimeTarget, wslHostDistro } = owner
const activeRuntimeEnvironmentId = runtimeTargetEnvironmentId(runtimeTarget)
const hasActiveRuntimeEnvironment = activeRuntimeEnvironmentId !== null
const providerAccountHostLabel = hasActiveRuntimeEnvironment
@@ -75,43 +73,26 @@ export function useCodexSwitcherController(codex: ProviderRateLimits) {
runtimeTarget
)
const codexAccountSyncKey = useAppStore((s) => getCodexAccountSyncKey(s.settings))
const { accounts, setAccounts, mountedRef, reloadUncertainAccountsOnOpen } =
useProviderAccountList('codex', runtimeTarget, codexAccountSyncKey, emptyCodexAccountsState())
const preparedParentReadRef = useRef(false)
useEffect(() => {
if (!parentMenuOpen) {
preparedParentReadRef.current = false
} else if (!preparedParentReadRef.current) {
preparedParentReadRef.current = reloadUncertainAccountsOnOpen()
}
}, [parentMenuOpen, reloadUncertainAccountsOnOpen])
const accountState = resolveCodexStatusAccountState(
settings,
accounts,
activeRuntimeEnvironmentId
)
// Why: keyed on owner id, not settings identity, so routine settings mutations don't re-run the remote snapshot fetch.
const loadAccounts = useCallback(async () => {
const snapshot = await fetchProviderAccountsSnapshot(runtimeTarget)
// Why: a failed Codex half is a substituted empty roster; keep prior state.
if (snapshot.failedProviders?.includes('codex')) {
console.error('Codex account list failed; keeping previous status bar state.')
return
}
if (mountedRef.current) {
setAccounts(snapshot.codex)
}
}, [runtimeTarget])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
useEffect(() => {
menuOpenRef.current = open
}, [open])
useEffect(() => {
// Load on demand at mount and resync account mutations without fetching again on submenu expansion.
void loadAccounts().catch((error) => {
console.error('Failed to load Codex accounts for status bar:', error)
})
}, [loadAccounts, codexAccountSyncKey])
const handleSelectAccount = async (
accountId: string | null,
target: CodexStatusRuntimeTarget
@@ -130,6 +111,8 @@ export function useCodexSwitcherController(codex: ProviderRateLimits) {
recordFeatureInteraction('codex-account-switching')
if (mountedRef.current) {
setAccounts(next)
} else {
refreshProviderAccountLists(runtimeTarget, 'codex')
}
// Why: remote selections live on the server; local GlobalSettings are untouched, so refetching is pure churn.
if (!hasActiveRuntimeEnvironment) {
@@ -158,6 +141,11 @@ export function useCodexSwitcherController(codex: ProviderRateLimits) {
}
} catch (error) {
console.error('Failed to switch Codex account from status bar:', error)
// Why: a rejected or timed-out selection may still have applied on the owner; re-read, never re-send.
refreshProviderAccountLists(runtimeTarget, 'codex', 'unknown')
if (!hasActiveRuntimeEnvironment) {
await fetchSettings().catch(() => {})
}
} finally {
if (mountedRef.current) {
setIsSwitching(false)
@@ -242,11 +230,17 @@ export function useCodexSwitcherController(codex: ProviderRateLimits) {
const handleOpenChange = useCallback(
(nextOpen: boolean): void => {
setOpen(nextOpen)
if (nextOpen) {
if (!preparedParentReadRef.current) {
reloadUncertainAccountsOnOpen()
}
preparedParentReadRef.current = false
}
if (nextOpen && !hasActiveRuntimeEnvironment) {
void fetchInactiveCodexAccountUsage()
}
},
[fetchInactiveCodexAccountUsage, hasActiveRuntimeEnvironment]
[fetchInactiveCodexAccountUsage, hasActiveRuntimeEnvironment, reloadUncertainAccountsOnOpen]
)
const selectedRuntimeKey = getCodexStatusRuntimeKey(
@@ -0,0 +1,156 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { refreshProviderAccountLists, useProviderAccountList } from './use-provider-account-list'
import {
emptyCodexAccountsState,
type ProviderAccountsSnapshot
} from '@/runtime/provider-accounts/observations/runtime-provider-accounts-client'
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
// Real hook and real snapshot client; only the host stream is a stand-in answered by hand.
function setup(environmentId: string) {
const streams: { send: (snapshot: ProviderAccountsSnapshot) => void; fail: () => void }[] = []
vi.stubGlobal('api', {
runtimeEnvironments: {
subscribe: async (
_args: unknown,
callbacks: {
onResponse: (response: unknown) => void
onError: (error: { code: string; message: string }) => void
}
) => {
streams.push({
send: (snapshot) =>
callbacks.onResponse({ ok: true, result: { type: 'snapshot', snapshot } }),
fail: () => callbacks.onError({ code: 'unreachable', message: 'host unreachable' })
})
return { unsubscribe: () => {} }
}
}
})
const target = { kind: 'environment', environmentId } as const
const mount = () =>
renderHook(() => useProviderAccountList('codex', target, '', emptyCodexAccountsState()))
const hostReports = (activeAccountId: string): ProviderAccountsSnapshot => ({
claude: { accounts: [], activeAccountId: null },
codex: { accounts: [], activeAccountId },
rateLimits: null
})
return { streams, target, mount, hostReports }
}
it.each(['before', 'after'] as const)(
'keeps a selection result when an earlier shared read resolves %s a fresh read',
async (order) => {
const { streams, mount, hostReports } = setup(`selection-${order}`)
const { result } = mount()
await act(async () => {})
const staleRead = streams[0]
act(() => result.current.setAccounts(hostReports('selected').codex))
const reload = result.current.loadAccounts()
await act(async () => {})
if (order === 'before') {
await act(async () => staleRead.send(hostReports('stale')))
expect(result.current.accounts.activeAccountId).toBe('selected')
}
// The retired request settling must not evict the newer one from sharing: this read is answered by it.
const sharedReload = result.current.loadAccounts()
await act(async () => {})
await act(async () => {
streams[1].send(hostReports('selected'))
await Promise.all([reload, sharedReload])
})
if (order === 'after') {
await act(async () => staleRead.send(hostReports('stale')))
}
expect(result.current.accounts.activeAccountId).toBe('selected')
expect(result.current.accountsLoaded).toBe(true)
}
)
it.each(['before', 'after'] as const)(
'shows what the host reports after an uncertain owner menu opens when the older read resolves %s',
async (order) => {
const { streams, target, mount, hostReports } = setup(`open-${order}`)
const { result } = mount()
await act(async () => streams[0].send(hostReports('initial')))
act(() => refreshProviderAccountLists(target, 'codex', 'unknown'))
await act(async () => {})
const olderRead = streams[1]
// The host applies the change here, after the older read was issued.
act(() => result.current.reloadUncertainAccountsOnOpen())
await act(async () => {})
const openRead = streams[2]
// A read issued after the open is as fresh as it, so the open's answer serves it.
const follower = result.current.loadAccounts()
await act(async () => {})
if (order === 'before') {
await act(async () => olderRead.send(hostReports('before-change')))
expect(result.current.accounts.activeAccountId).toBe('initial')
}
await act(async () => {
openRead.send(hostReports('after-change'))
await follower
})
if (order === 'after') {
await act(async () => olderRead.send(hostReports('before-change')))
}
expect(result.current.accounts.activeAccountId).toBe('after-change')
}
)
it.each(['answered', 'failed'] as const)(
'reads again at the first open after an uncertain owner read %s before a late change',
async (earlierRead) => {
const { streams, target, mount, hostReports } = setup(`late-${earlierRead}`)
const { result } = mount()
await act(async () => streams[0].send(hostReports('before-change')))
vi.spyOn(console, 'error').mockImplementation(() => {})
act(() => refreshProviderAccountLists(target, 'codex', 'unknown'))
await act(async () => {})
await act(async () =>
earlierRead === 'answered' ? streams[1].send(hostReports('before-change')) : streams[1].fail()
)
expect(result.current.accounts.activeAccountId).toBe('before-change')
// The selection applies on the host only now, long after that read settled.
act(() => result.current.reloadUncertainAccountsOnOpen())
await act(async () => {})
await act(async () => streams[2].send(hostReports('after-change')))
expect(result.current.accounts.activeAccountId).toBe('after-change')
}
)
it.each(['before', 'after'] as const)(
'shows what the host reports when a menu mounted for an uncertain owner first opens %s a late change',
async (opens) => {
const { streams, target, mount, hostReports } = setup(`mounted-${opens}`)
mount()
await act(async () => {})
act(() => refreshProviderAccountLists(target, 'codex', 'unknown'))
await act(async () => {})
const olderRead = streams[1]
const returned = mount()
await act(async () => {})
// Anything already asked of the host answers with the roster from before the change.
const askedBeforeOpen = [...streams]
if (opens === 'after') {
await act(async () =>
askedBeforeOpen.forEach((read) => read.send(hostReports('before-change')))
)
}
act(() => returned.result.current.reloadUncertainAccountsOnOpen())
await act(async () => {})
if (opens === 'before') {
await act(async () => olderRead.send(hostReports('before-change')))
}
const openRead = streams.find((read) => !askedBeforeOpen.includes(read))!
await act(async () => openRead.send(hostReports('after-change')))
expect(returned.result.current.accounts.activeAccountId).toBe('after-change')
}
)
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from
import {
fetchProviderAccountsSnapshot,
getProviderAccountsOwnerKey,
invalidateProviderAccountsSnapshot,
type ProviderAccountsSnapshot
} from '@/runtime/provider-accounts/observations/runtime-provider-accounts-client'
import { observedProviderAccounts } from '@/runtime/provider-accounts/observations/observed-provider-accounts'
@@ -9,6 +10,41 @@ import type { RuntimeClientTarget } from '../../../../shared/runtime-client-targ
const noSubscription = () => () => {}
const noSnapshot = () => null
const accountListRefreshListeners = new Map<string, Set<() => void>>()
// Owners whose last account change may have applied on the host unseen. Never cleared: no later response proves that change settled.
const uncertainAccountLists = new Set<string>()
// What each owner's list last showed, so an uncertain owner's menu keeps it on screen while its open read runs.
const lastKnownAccountLists: {
[Provider in 'claude' | 'codex']: Map<string, ProviderAccountsSnapshot[Provider]>
} = { claude: new Map(), codex: new Map() }
// Per list instance: only its latest read or selection result may apply.
type AccountListInstance = { current: boolean; ownerKey: string }
const accountListRequestVersions = new WeakMap<AccountListInstance, number>()
function startAccountListRequest(instance: AccountListInstance): number {
const version = (accountListRequestVersions.get(instance) ?? 0) + 1
accountListRequestVersions.set(instance, version)
return version
}
function accountListKey(target: RuntimeClientTarget, provider: 'claude' | 'codex'): string {
return `${getProviderAccountsOwnerKey(target)}:${provider}`
}
/** Reloads every mounted list of this owner. `unknown` is a rejected change, which may still have applied. */
export function refreshProviderAccountLists(
target: RuntimeClientTarget,
provider: 'claude' | 'codex',
outcome: 'applied' | 'unknown' = 'applied'
): void {
const key = accountListKey(target, provider)
if (outcome === 'unknown') {
uncertainAccountLists.add(key)
}
invalidateProviderAccountsSnapshot(target)
accountListRefreshListeners.get(key)?.forEach((refresh) => refresh())
}
/** The switchers share snapshot loading; a failed provider half never replaces its roster. */
export function useProviderAccountList<Provider extends 'claude' | 'codex'>(
@@ -41,7 +77,14 @@ export function useProviderAccountList<Provider extends 'claude' | 'codex'>(
observing ? observation.getSnapshot : noSnapshot,
noSnapshot
)
const [local, setLocal] = useState({ ownerKey, accounts: initial, known: false })
const [local, setLocal] = useState(() => ({
ownerKey,
accounts:
(uncertainAccountLists.has(ownerKey)
? lastKnownAccountLists[provider].get(ownerKey)
: undefined) ?? initial,
known: false
}))
const accounts = observing
? (answer?.accounts ?? initial)
: local.ownerKey === ownerKey
@@ -55,9 +98,12 @@ export function useProviderAccountList<Provider extends 'claude' | 'codex'>(
if (!mountedRef.current) {
return
}
startAccountListRequest(mountedRef)
invalidateProviderAccountsSnapshot(ownerTarget)
if (observing) {
observation.setAccounts(next)
} else {
lastKnownAccountLists[provider].set(ownerKey, next)
setLocal((previous) => ({
ownerKey,
accounts: next,
@@ -65,28 +111,67 @@ export function useProviderAccountList<Provider extends 'claude' | 'codex'>(
}))
}
},
[mountedRef, observing, observation, ownerKey]
[mountedRef, observing, observation, ownerKey, ownerTarget, provider]
)
const loadAccounts = useCallback(async () => {
if (!mountedRef.current) {
return
}
const requestVersion = startAccountListRequest(mountedRef)
const snapshot = await fetchProviderAccountsSnapshot(ownerTarget)
if (!mountedRef.current || accountListRequestVersions.get(mountedRef) !== requestVersion) {
return
}
if (snapshot.failedProviders?.includes(provider)) {
console.error(`${provider} account list failed; keeping previous state.`)
return
}
if (mountedRef.current) {
if (observing) {
observation.setAccounts(snapshot[provider])
} else {
setLocal({ ownerKey, accounts: snapshot[provider], known: true })
}
if (observing) {
observation.setAccounts(snapshot[provider])
} else {
lastKnownAccountLists[provider].set(ownerKey, snapshot[provider])
setLocal({ ownerKey, accounts: snapshot[provider], known: true })
}
}, [provider, ownerTarget, ownerKey, observing, observation, mountedRef])
useEffect(() => {
if (!observing) {
void loadAccounts().catch((error: unknown) =>
console.error(`Failed to load ${provider} accounts:`, error)
)
const logLoadFailure = useCallback(
(error: unknown) => console.error(`Failed to load ${provider} accounts:`, error),
[provider]
)
// One fresh read per admission; the parent menu can admit it before the submenu expands.
const reloadUncertainAccountsOnOpen = useCallback((): boolean => {
if (!uncertainAccountLists.has(ownerKey)) {
return false
}
}, [loadAccounts, observing, provider, syncKey])
return { accounts, setAccounts, accountsLoaded, loadAccounts, mountedRef }
// Why: a request issued before this admission may predate the unseen change.
invalidateProviderAccountsSnapshot(ownerTarget)
void loadAccounts().catch(logLoadFailure)
return true
}, [loadAccounts, logLoadFailure, ownerKey, ownerTarget])
useEffect(() => {
if (!observing && !uncertainAccountLists.has(ownerKey)) {
void loadAccounts().catch(logLoadFailure)
}
}, [loadAccounts, logLoadFailure, observing, ownerKey, syncKey])
useEffect(() => {
const refresh = () => {
void loadAccounts().catch(logLoadFailure)
}
const listeners = accountListRefreshListeners.get(ownerKey) ?? new Set()
listeners.add(refresh)
accountListRefreshListeners.set(ownerKey, listeners)
return () => {
listeners.delete(refresh)
if (listeners.size === 0) {
accountListRefreshListeners.delete(ownerKey)
}
}
}, [loadAccounts, logLoadFailure, ownerKey])
return {
accounts,
setAccounts,
accountsLoaded,
loadAccounts,
reloadUncertainAccountsOnOpen,
mountedRef
}
}
+2 -1
View File
@@ -4670,7 +4670,8 @@
"grokUsageMenu": "Grok-Nutzung",
"floatingTerminalNewActivity": "{{label}}, neue Aktivität",
"4fba7dc1e7": "{{value0}} Guthaben",
"codexSignInSuccess": "Bei Codex angemeldet",
"codexSignInSuccessOnRuntime": "Bei Codex angemeldet ({{runtime}})",
"codexSignInFailedOnRuntime": "Anmeldung bei Codex fehlgeschlagen ({{runtime}}).",
"codexSignInError": "Anmeldung bei Codex fehlgeschlagen. Bitte erneut versuchen.",
"cursorUsageMenu": "Cursor-Nutzung",
"hiddenUsageProviders": "Außerdem: {{value0}}",
+2 -1
View File
@@ -5258,7 +5258,8 @@
"4fba7dc1e7": "{{value0}} bal",
"4025a6f62f": "Unlimited",
"a95969101f": "{{value0}} credits",
"codexSignInSuccess": "Signed in to Codex",
"codexSignInSuccessOnRuntime": "Signed in to Codex on {{runtime}}",
"codexSignInFailedOnRuntime": "Codex sign-in failed on {{runtime}}.",
"codexSignInError": "Codex sign-in failed. Please try again.",
"cursorUsageMenu": "Cursor Usage",
"hiddenUsageProviders": "Also: {{value0}}",
+2 -1
View File
@@ -4178,7 +4178,8 @@
"grokUsageMenu": "Uso de Grok",
"floatingTerminalNewActivity": "{{label}}, nueva actividad",
"4fba7dc1e7": "saldo {{value0}}",
"codexSignInSuccess": "Sesión iniciada en Codex",
"codexSignInSuccessOnRuntime": "Sesión iniciada en Codex ({{runtime}})",
"codexSignInFailedOnRuntime": "No se pudo iniciar sesión en Codex ({{runtime}}).",
"codexSignInError": "No se pudo iniciar sesión en Codex. Inténtalo de nuevo.",
"cursorUsageMenu": "Uso de Cursor",
"hiddenUsageProviders": "También: {{value0}}",
+2 -1
View File
@@ -4441,7 +4441,8 @@
"grokUsageMenu": "Utilisation Grok",
"floatingTerminalNewActivity": "{{label}}, nouvelle activité",
"4fba7dc1e7": "solde {{value0}}",
"codexSignInSuccess": "Connecté à Codex",
"codexSignInSuccessOnRuntime": "Connecté à Codex ({{runtime}})",
"codexSignInFailedOnRuntime": "Échec de la connexion à Codex ({{runtime}}).",
"codexSignInError": "Échec de la connexion à Codex. Veuillez réessayer.",
"cursorUsageMenu": "Utilisation Cursor",
"hiddenUsageProviders": "Aussi : {{value0}}",
+2 -1
View File
@@ -4100,7 +4100,8 @@
"grokUsageMenu": "Grok の使用状況",
"floatingTerminalNewActivity": "{{label}}、新規アクティビティ",
"4fba7dc1e7": "残高 {{value0}}",
"codexSignInSuccess": "Codex にサインインしました",
"codexSignInSuccessOnRuntime": "Codex にサインインしました({{runtime}})",
"codexSignInFailedOnRuntime": "Codex のサインインに失敗しました({{runtime}})。",
"codexSignInError": "Codex のサインインに失敗しました。もう一度お試しください。",
"cursorUsageMenu": "Cursor の使用状況",
"hiddenUsageProviders": "ほかに: {{value0}}",
+2 -1
View File
@@ -4097,7 +4097,8 @@
"grokUsageMenu": "Grok 사용량",
"floatingTerminalNewActivity": "{{label}}, 새 활동",
"4fba7dc1e7": "잔액 {{value0}}",
"codexSignInSuccess": "Codex에 로그인했습니다",
"codexSignInSuccessOnRuntime": "Codex에 로그인했습니다({{runtime}})",
"codexSignInFailedOnRuntime": "Codex 로그인에 실패했습니다({{runtime}}).",
"codexSignInError": "Codex 로그인에 실패했습니다. 다시 시도해 주세요.",
"cursorUsageMenu": "Cursor 사용량",
"hiddenUsageProviders": "그 외: {{value0}}",
+2 -1
View File
@@ -3412,7 +3412,8 @@
"antigravityUsage": "Uso do Antigravity",
"grokUsageMenu": "Uso do Grok",
"floatingTerminalNewActivity": "{{label}}, nova atividade",
"codexSignInSuccess": "Login realizado no Codex",
"codexSignInSuccessOnRuntime": "Login realizado no Codex ({{runtime}})",
"codexSignInFailedOnRuntime": "Falha no login do Codex ({{runtime}}).",
"codexSignInError": "Falha no login do Codex. Tente novamente.",
"4fba7dc1e7": "Saldo {{value0}}",
"4025a6f62f": "Ilimitado",
+2 -1
View File
@@ -4786,7 +4786,8 @@
"4fba7dc1e7": "баланс {{value0}}",
"4025a6f62f": "Без ограничений",
"a95969101f": "Кредитов: {{value0}}",
"codexSignInSuccess": "Вход в Codex выполнен",
"codexSignInSuccessOnRuntime": "Вход в Codex выполнен ({{runtime}})",
"codexSignInFailedOnRuntime": "Не удалось войти в Codex ({{runtime}}).",
"codexSignInError": "Не удалось войти в Codex. Повторите попытку.",
"cursorUsageMenu": "Использование Cursor",
"hiddenUsageProviders": "Также: {{value0}}",
+2 -1
View File
@@ -4638,7 +4638,8 @@
"4fba7dc1e7": "{{value0}} bakiye",
"4025a6f62f": "Sınırsız",
"a95969101f": "{{value0}} kredi",
"codexSignInSuccess": "Codex'te oturum açıldı",
"codexSignInSuccessOnRuntime": "Codex'te oturum açıldı ({{runtime}})",
"codexSignInFailedOnRuntime": "Codex'te oturum açılamadı ({{runtime}}).",
"codexSignInError": "Codex'te oturum açılamadı. Lütfen tekrar deneyin.",
"cursorUsageMenu": "Cursor Kullanımı",
"hiddenUsageProviders": "Ayrıca: {{value0}}",
+2 -1
View File
@@ -4582,7 +4582,8 @@
"grokUsageMenu": "Grok 用量",
"floatingTerminalNewActivity": "{{label}},有新活動",
"4fba7dc1e7": "餘額 {{value0}}",
"codexSignInSuccess": "已登入 Codex",
"codexSignInSuccessOnRuntime": "已登入 Codex({{runtime}})",
"codexSignInFailedOnRuntime": "Codex 登入失敗({{runtime}})。",
"codexSignInError": "Codex 登入失敗。請再試一次。",
"cursorUsageMenu": "Cursor 用量",
"hiddenUsageProviders": "另有:{{value0}}",
+2 -1
View File
@@ -4295,7 +4295,8 @@
"grokUsageMenu": "Grok 使用情况",
"floatingTerminalNewActivity": "{{label}},有新活动",
"4fba7dc1e7": "余额 {{value0}}",
"codexSignInSuccess": "已登录 Codex",
"codexSignInSuccessOnRuntime": "已登录 Codex({{runtime}})",
"codexSignInFailedOnRuntime": "Codex 登录失败({{runtime}})。",
"codexSignInError": "Codex 登录失败,请重试",
"cursorUsageMenu": "Cursor 用量",
"hiddenUsageProviders": "另有:{{value0}}",
@@ -188,6 +188,11 @@ export function watchProviderAccounts(
}
}
// Reads after a mutation must not join a snapshot started before it completed.
export function invalidateProviderAccountsSnapshot(target: RuntimeClientTarget): void {
pendingProviderAccountsSnapshots.delete(getProviderAccountsOwnerKey(target))
}
// One-shot account snapshot for status-bar switcher menus.
export function fetchProviderAccountsSnapshot(
target: RuntimeClientTarget