diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 94adc8e7a24..f481780b8d5 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -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 diff --git a/src/renderer/src/components/status-bar/CodexSwitcherMenu.owner-lifetime.test.tsx b/src/renderer/src/components/status-bar/CodexSwitcherMenu.owner-lifetime.test.tsx new file mode 100644 index 00000000000..ae300a99ab3 --- /dev/null +++ b/src/renderer/src/components/status-bar/CodexSwitcherMenu.owner-lifetime.test.tsx @@ -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()), + fetchProviderAccountsSnapshot: mocks.load, + selectCodexProviderAccount: mocks.select + }) +) +vi.mock('@/lib/agent-session/accounts/codex/codex-session-restart', async (original) => ({ + ...(await original()), + 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 + }) => ( +
+ + {children} +
+ ) +})) +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenuLabel: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuSeparator: () => null, + DropdownMenuItem: ({ + children, + disabled, + onSelect + }: { + children: ReactNode + disabled?: boolean + onSelect: (event: { preventDefault: () => void }) => void + }) => ( +
{ + if (!disabled) { + onSelect({ preventDefault: () => {} }) + } + }} + > + {children} +
+ ) +})) +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() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((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() + const returnedRoster = deferred() + 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() + 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() + const replacement = deferred() + mocks.load.mockResolvedValueOnce(snapshot('remote-a')) + configureOwner('remote-a') + render() + 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() + 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() + 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() + mocks.load.mockResolvedValueOnce(snapshot('remote-a')).mockReturnValueOnce(pending.promise) + configureOwner('remote-a') + render() + 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() + 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() + 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() + const newer = deferred() + mocks.select.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise) + configureOwner('ordering-host') + render() + 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() + const reread = deferred() + mocks.select.mockReturnValueOnce(selection.promise) + configureOwner('rejecting-host') + render() + 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() + 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() + 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() + 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() + 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() + mocks.load.mockReturnValueOnce(fresh.promise) + render() + 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) +}) diff --git a/src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx b/src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx index ad5b0d15543..308fcb7f123 100644 --- a/src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx +++ b/src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx @@ -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 ( + + ) +} + +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) => { diff --git a/src/renderer/src/components/status-bar/StatusBarSurface.tsx b/src/renderer/src/components/status-bar/StatusBarSurface.tsx index 785b795ff0d..5ddf8aa09d8 100644 --- a/src/renderer/src/components/status-bar/StatusBarSurface.tsx +++ b/src/renderer/src/components/status-bar/StatusBarSurface.tsx @@ -251,6 +251,7 @@ export function StatusBarSurface({ return ( reauthenticatingAccountId: string | null recordFeatureInteraction: AppState['recordFeatureInteraction'] - setAccounts: Dispatch> + setAccounts: (accounts: CodexRateLimitAccountsState) => void setReauthenticatingAccountId: Dispatch> } @@ -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 { diff --git a/src/renderer/src/components/status-bar/use-codex-switcher-controller.ts b/src/renderer/src/components/status-bar/use-codex-switcher-controller.ts index 145243ffd32..31436e2a1dc 100644 --- a/src/renderer/src/components/status-bar/use-codex-switcher-controller.ts +++ b/src/renderer/src/components/status-bar/use-codex-switcher-controller.ts @@ -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({ - accounts: [], - activeAccountId: null - }) const [isSwitching, setIsSwitching] = useState(false) const [isRedeemingReset, setIsRedeemingReset] = useState(false) const [reauthenticatingAccountId, setReauthenticatingAccountId] = useState(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( diff --git a/src/renderer/src/components/status-bar/use-provider-account-list.test.tsx b/src/renderer/src/components/status-bar/use-provider-account-list.test.tsx new file mode 100644 index 00000000000..e922ec7ec19 --- /dev/null +++ b/src/renderer/src/components/status-bar/use-provider-account-list.test.tsx @@ -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') + } +) diff --git a/src/renderer/src/components/status-bar/use-provider-account-list.ts b/src/renderer/src/components/status-bar/use-provider-account-list.ts index f833ba141df..8cb876a6130 100644 --- a/src/renderer/src/components/status-bar/use-provider-account-list.ts +++ b/src/renderer/src/components/status-bar/use-provider-account-list.ts @@ -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 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() +// 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 +} = { 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() + +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( @@ -41,7 +77,14 @@ export function useProviderAccountList( 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( 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( })) } }, - [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 + } } diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index 062bab6b7c7..4f507e9af98 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -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}}", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a5efbd1c672..82dbd8cb8d7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c85e0fedcdd..9d0de9b77bd 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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}}", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 91e2377e60a..875ba70d405 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 511abc5dd2d..28e9fd982f9 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4100,7 +4100,8 @@ "grokUsageMenu": "Grok の使用状況", "floatingTerminalNewActivity": "{{label}}、新規アクティビティ", "4fba7dc1e7": "残高 {{value0}}", - "codexSignInSuccess": "Codex にサインインしました", + "codexSignInSuccessOnRuntime": "Codex にサインインしました({{runtime}})", + "codexSignInFailedOnRuntime": "Codex のサインインに失敗しました({{runtime}})。", "codexSignInError": "Codex のサインインに失敗しました。もう一度お試しください。", "cursorUsageMenu": "Cursor の使用状況", "hiddenUsageProviders": "ほかに: {{value0}}", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e15a1ef2e38..7ef6ca0826b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4097,7 +4097,8 @@ "grokUsageMenu": "Grok 사용량", "floatingTerminalNewActivity": "{{label}}, 새 활동", "4fba7dc1e7": "잔액 {{value0}}", - "codexSignInSuccess": "Codex에 로그인했습니다", + "codexSignInSuccessOnRuntime": "Codex에 로그인했습니다({{runtime}})", + "codexSignInFailedOnRuntime": "Codex 로그인에 실패했습니다({{runtime}}).", "codexSignInError": "Codex 로그인에 실패했습니다. 다시 시도해 주세요.", "cursorUsageMenu": "Cursor 사용량", "hiddenUsageProviders": "그 외: {{value0}}", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index e6a95271fa6..e93ab6054f6 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index 2a19b39d005..b62c1fb9094 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -4786,7 +4786,8 @@ "4fba7dc1e7": "баланс {{value0}}", "4025a6f62f": "Без ограничений", "a95969101f": "Кредитов: {{value0}}", - "codexSignInSuccess": "Вход в Codex выполнен", + "codexSignInSuccessOnRuntime": "Вход в Codex выполнен ({{runtime}})", + "codexSignInFailedOnRuntime": "Не удалось войти в Codex ({{runtime}}).", "codexSignInError": "Не удалось войти в Codex. Повторите попытку.", "cursorUsageMenu": "Использование Cursor", "hiddenUsageProviders": "Также: {{value0}}", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index 84d6397c7f8..523dd9c0a2a 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -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}}", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 9a250e4ea66..c3d2b3f0be8 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -4582,7 +4582,8 @@ "grokUsageMenu": "Grok 用量", "floatingTerminalNewActivity": "{{label}},有新活動", "4fba7dc1e7": "餘額 {{value0}}", - "codexSignInSuccess": "已登入 Codex", + "codexSignInSuccessOnRuntime": "已登入 Codex({{runtime}})", + "codexSignInFailedOnRuntime": "Codex 登入失敗({{runtime}})。", "codexSignInError": "Codex 登入失敗。請再試一次。", "cursorUsageMenu": "Cursor 用量", "hiddenUsageProviders": "另有:{{value0}}", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2d291616a23..ad3b6af0bbf 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4295,7 +4295,8 @@ "grokUsageMenu": "Grok 使用情况", "floatingTerminalNewActivity": "{{label}},有新活动", "4fba7dc1e7": "余额 {{value0}}", - "codexSignInSuccess": "已登录 Codex", + "codexSignInSuccessOnRuntime": "已登录 Codex({{runtime}})", + "codexSignInFailedOnRuntime": "Codex 登录失败({{runtime}})。", "codexSignInError": "Codex 登录失败,请重试", "cursorUsageMenu": "Cursor 用量", "hiddenUsageProviders": "另有:{{value0}}", diff --git a/src/renderer/src/runtime/provider-accounts/observations/runtime-provider-accounts-client.ts b/src/renderer/src/runtime/provider-accounts/observations/runtime-provider-accounts-client.ts index 28babdd08aa..612d322a695 100644 --- a/src/renderer/src/runtime/provider-accounts/observations/runtime-provider-accounts-client.ts +++ b/src/renderer/src/runtime/provider-accounts/observations/runtime-provider-accounts-client.ts @@ -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