mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
Fix account switcher state
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -4100,7 +4100,8 @@
|
||||
"grokUsageMenu": "Grok の使用状況",
|
||||
"floatingTerminalNewActivity": "{{label}}、新規アクティビティ",
|
||||
"4fba7dc1e7": "残高 {{value0}}",
|
||||
"codexSignInSuccess": "Codex にサインインしました",
|
||||
"codexSignInSuccessOnRuntime": "Codex にサインインしました({{runtime}})",
|
||||
"codexSignInFailedOnRuntime": "Codex のサインインに失敗しました({{runtime}})。",
|
||||
"codexSignInError": "Codex のサインインに失敗しました。もう一度お試しください。",
|
||||
"cursorUsageMenu": "Cursor の使用状況",
|
||||
"hiddenUsageProviders": "ほかに: {{value0}}",
|
||||
|
||||
@@ -4097,7 +4097,8 @@
|
||||
"grokUsageMenu": "Grok 사용량",
|
||||
"floatingTerminalNewActivity": "{{label}}, 새 활동",
|
||||
"4fba7dc1e7": "잔액 {{value0}}",
|
||||
"codexSignInSuccess": "Codex에 로그인했습니다",
|
||||
"codexSignInSuccessOnRuntime": "Codex에 로그인했습니다({{runtime}})",
|
||||
"codexSignInFailedOnRuntime": "Codex 로그인에 실패했습니다({{runtime}}).",
|
||||
"codexSignInError": "Codex 로그인에 실패했습니다. 다시 시도해 주세요.",
|
||||
"cursorUsageMenu": "Cursor 사용량",
|
||||
"hiddenUsageProviders": "그 외: {{value0}}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4786,7 +4786,8 @@
|
||||
"4fba7dc1e7": "баланс {{value0}}",
|
||||
"4025a6f62f": "Без ограничений",
|
||||
"a95969101f": "Кредитов: {{value0}}",
|
||||
"codexSignInSuccess": "Вход в Codex выполнен",
|
||||
"codexSignInSuccessOnRuntime": "Вход в Codex выполнен ({{runtime}})",
|
||||
"codexSignInFailedOnRuntime": "Не удалось войти в Codex ({{runtime}}).",
|
||||
"codexSignInError": "Не удалось войти в Codex. Повторите попытку.",
|
||||
"cursorUsageMenu": "Использование Cursor",
|
||||
"hiddenUsageProviders": "Также: {{value0}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -4582,7 +4582,8 @@
|
||||
"grokUsageMenu": "Grok 用量",
|
||||
"floatingTerminalNewActivity": "{{label}},有新活動",
|
||||
"4fba7dc1e7": "餘額 {{value0}}",
|
||||
"codexSignInSuccess": "已登入 Codex",
|
||||
"codexSignInSuccessOnRuntime": "已登入 Codex({{runtime}})",
|
||||
"codexSignInFailedOnRuntime": "Codex 登入失敗({{runtime}})。",
|
||||
"codexSignInError": "Codex 登入失敗。請再試一次。",
|
||||
"cursorUsageMenu": "Cursor 用量",
|
||||
"hiddenUsageProviders": "另有:{{value0}}",
|
||||
|
||||
@@ -4295,7 +4295,8 @@
|
||||
"grokUsageMenu": "Grok 使用情况",
|
||||
"floatingTerminalNewActivity": "{{label}},有新活动",
|
||||
"4fba7dc1e7": "余额 {{value0}}",
|
||||
"codexSignInSuccess": "已登录 Codex",
|
||||
"codexSignInSuccessOnRuntime": "已登录 Codex({{runtime}})",
|
||||
"codexSignInFailedOnRuntime": "Codex 登录失败({{runtime}})。",
|
||||
"codexSignInError": "Codex 登录失败,请重试",
|
||||
"cursorUsageMenu": "Cursor 用量",
|
||||
"hiddenUsageProviders": "另有:{{value0}}",
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user