mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(accounts): free the queue for a switch, not only for another add
Switching or removing an account shares the mutation queue an abandoned sign-in was holding, so the commonest thing a user does after giving up — pick a different account — still spun for the whole deadline while Add recovered instantly. Both now supersede, as does the Claude side. Every caller is a person: the two IPC handlers and the mobile RPC methods. No poll, sync or CLI path reaches them, and a sign-in that already wrote credentials refuses the cancel, so a switch cannot discard one that succeeded. Also from review: the Cancel button regains the gap its Claude twin has (layout is allowed by the design-system rule; only the colour override was not), and the URL subscription says what it is — registration for the process's lifetime, with no teardown to hand back.
This commit is contained in:
@@ -93,10 +93,12 @@ export class ClaudeAccountService {
|
||||
}
|
||||
|
||||
async removeAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
|
||||
this.supersedePendingLogin()
|
||||
return this.serializeMutation(() => this.selection.remove(accountId))
|
||||
}
|
||||
|
||||
async selectAccount(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
|
||||
this.supersedePendingLogin()
|
||||
return this.serializeMutation(() => this.selection.select(accountId))
|
||||
}
|
||||
|
||||
@@ -104,6 +106,7 @@ export class ClaudeAccountService {
|
||||
accountId: string | null,
|
||||
target?: ClaudeAccountSelectionTarget
|
||||
): Promise<ClaudeRateLimitAccountsState> {
|
||||
this.supersedePendingLogin()
|
||||
return this.serializeMutation(() => this.selection.select(accountId, target))
|
||||
}
|
||||
|
||||
@@ -112,8 +115,8 @@ export class ClaudeAccountService {
|
||||
}
|
||||
|
||||
// Why before the queue, not inside it: the abandoned login owns the queue slot
|
||||
// the next add is waiting for. Only add/reauthenticate open a browser, so only
|
||||
// they supersede — never serializeMutation, which background work also uses.
|
||||
// every later account action waits for. Called from the four the user drives,
|
||||
// never from serializeMutation, which background work also uses.
|
||||
private supersedePendingLogin(): void {
|
||||
if (this.cancelPendingLogin()) {
|
||||
console.info(
|
||||
|
||||
@@ -58,9 +58,10 @@ function createStubLoginChild(): StubLoginChild {
|
||||
async function createServiceWithHangingLogin(): Promise<{
|
||||
service: {
|
||||
addAccount: () => Promise<{ accounts: { email: string }[] }>
|
||||
selectAccount: (accountId: string | null) => Promise<unknown>
|
||||
cancelPendingLogin: () => boolean
|
||||
getPendingLoginUrl: () => string | null
|
||||
subscribePendingLoginUrl: (listener: (url: string | null) => void) => void
|
||||
onPendingLoginUrlChanged: (listener: (url: string | null) => void) => void
|
||||
}
|
||||
children: StubLoginChild[]
|
||||
/** The `CODEX_HOME` each login was spawned against. */
|
||||
@@ -119,6 +120,19 @@ describe('CodexAccountService abandoned login', () => {
|
||||
await retryRejection
|
||||
})
|
||||
|
||||
it('frees the queue for a plain account switch too, not only for another add', async () => {
|
||||
const { service, children } = await createServiceWithHangingLogin()
|
||||
const abandoned = service.addAccount()
|
||||
const abandonedRejection = expect(abandoned).rejects.toThrow('Codex sign-in was cancelled.')
|
||||
await vi.waitUntil(() => children.length === 1)
|
||||
|
||||
// Why: switching to the system default is the commonest thing a user does
|
||||
// after giving up on a sign-in, and it shares the add's mutation queue.
|
||||
await service.selectAccount(null)
|
||||
await abandonedRejection
|
||||
expect(children[0].kill).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to cancel a sign-in that already wrote credentials, and keeps the account', async () => {
|
||||
const { service, children, loginHomes } = await createServiceWithHangingLogin()
|
||||
const pending = service.addAccount()
|
||||
@@ -156,7 +170,7 @@ describe('CodexAccountService abandoned login', () => {
|
||||
it('publishes the sign-in link codex prints and drops it when the login ends', async () => {
|
||||
const { service, children } = await createServiceWithHangingLogin()
|
||||
const published: (string | null)[] = []
|
||||
service.subscribePendingLoginUrl((url) => published.push(url))
|
||||
service.onPendingLoginUrlChanged((url) => published.push(url))
|
||||
|
||||
const pending = service.addAccount()
|
||||
const rejection = expect(pending).rejects.toThrow('Codex sign-in was cancelled.')
|
||||
|
||||
@@ -178,7 +178,8 @@ export class CodexAccountService {
|
||||
return this.pendingLoginUrl
|
||||
}
|
||||
|
||||
subscribePendingLoginUrl(listener: (url: string | null) => void): void {
|
||||
/** Registration lasts the process's lifetime; there is no teardown to hand back. */
|
||||
onPendingLoginUrlChanged(listener: (url: string | null) => void): void {
|
||||
this.pendingLoginUrlListeners.add(listener)
|
||||
}
|
||||
|
||||
@@ -195,8 +196,8 @@ export class CodexAccountService {
|
||||
}
|
||||
|
||||
// Why before the queue, not inside it: the abandoned login owns the queue slot
|
||||
// the next add is waiting for. Only add/reauthenticate open a browser, so only
|
||||
// they supersede — never serializeMutation, which background work also uses.
|
||||
// every later account action waits for. Called from the four the user drives,
|
||||
// never from serializeMutation, which background reset-credit work also uses.
|
||||
private supersedePendingLogin(): void {
|
||||
if (this.cancelPendingLogin()) {
|
||||
console.info('[codex-accounts] Cancelled a pending Codex login superseded by a new request.')
|
||||
@@ -225,10 +226,12 @@ export class CodexAccountService {
|
||||
}
|
||||
|
||||
async removeAccount(accountId: string): Promise<CodexRateLimitAccountsState> {
|
||||
this.supersedePendingLogin()
|
||||
return this.serializeMutation(() => this.selection.remove(accountId))
|
||||
}
|
||||
|
||||
async selectAccount(accountId: string | null): Promise<CodexRateLimitAccountsState> {
|
||||
this.supersedePendingLogin()
|
||||
return this.serializeMutation(() => this.selection.select(accountId))
|
||||
}
|
||||
|
||||
@@ -236,6 +239,7 @@ export class CodexAccountService {
|
||||
accountId: string | null,
|
||||
target?: CodexAccountSelectionTarget
|
||||
): Promise<CodexRateLimitAccountsState> {
|
||||
this.supersedePendingLogin()
|
||||
return this.serializeMutation(() => this.selection.select(accountId, target))
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export function registerCodexAccountHandlers(
|
||||
ipcMain.handle('codexAccounts:pendingLoginUrl', () => codexAccounts.getPendingLoginUrl())
|
||||
// Why: Settings can open after the login already printed its link, so the
|
||||
// renderer reads the current value on mount and this only carries changes.
|
||||
codexAccounts.subscribePendingLoginUrl(broadcastCodexPendingLoginUrl)
|
||||
codexAccounts.onPendingLoginUrlChanged(broadcastCodexPendingLoginUrl)
|
||||
ipcMain.handle(
|
||||
'codexAccounts:reauthenticate',
|
||||
(_event, args: { accountId: string; activateIfSelectionWasEmpty?: boolean }) =>
|
||||
|
||||
@@ -187,6 +187,7 @@ export function renderCodexAccountsSection(model: AccountsPaneSectionModel): Rea
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => void window.api.codexAccounts.cancelPendingLogin()}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<X />
|
||||
{translate('auto.components.settings.AccountsPane.dbb9626ed1', 'Cancel')}
|
||||
|
||||
Reference in New Issue
Block a user