mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
fix(accounts): restart running Claude sessions when the account is switched from the phone (#36933)
A switch over runtime RPC (the phone's Accounts screen) changed the selection but never reached the window's running-session handling, which only the status bar and Settings called after their own IPC switch. The RPC path now reports the lane and outgoing/incoming account to the main window, which runs the same handler. The web client handles its own switches and opts out.
This commit is contained in:
@@ -136,7 +136,7 @@ export function pruneInvalidClaudeRuntimeSelection(
|
||||
}
|
||||
|
||||
export function getClaudeSelectionTargetForAccount(
|
||||
account: ClaudeManagedAccount
|
||||
account: Pick<ClaudeManagedAccount, 'managedAuthRuntime' | 'wslDistro'>
|
||||
): ClaudeAccountSelectionTarget {
|
||||
if (account.managedAuthRuntime === 'wsl') {
|
||||
return { runtime: 'wsl', wslDistro: account.wslDistro ?? null }
|
||||
|
||||
@@ -272,7 +272,7 @@ export abstract class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeW
|
||||
|
||||
protected ptyControllerInventoryGenerationByProvider = new Map<string, number>()
|
||||
|
||||
protected readonly accounts = new RuntimeAccountController()
|
||||
protected readonly accounts = new RuntimeAccountController(() => this.notifier)
|
||||
|
||||
protected readonly mobileSpeech = new RuntimeMobileSpeechCatalog(() => this.store)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ListAccountsParams,
|
||||
RemoveAccountParams,
|
||||
SelectAccountParams,
|
||||
SelectClaudeAccountParams,
|
||||
SelectCodexAccountForTargetParams,
|
||||
HostAccountSignInStartParams,
|
||||
HostAccountSignInWaitParams,
|
||||
@@ -142,8 +143,11 @@ const ACCOUNT_METHOD_DEFINITIONS = [
|
||||
defineMethod({
|
||||
name: 'accounts.selectClaude',
|
||||
permission: 'accounts-admin',
|
||||
params: SelectAccountParams,
|
||||
handler: async (params, { runtime }) => runtime.selectClaudeAccount(params.accountId)
|
||||
params: SelectClaudeAccountParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.selectClaudeAccount(params.accountId, {
|
||||
movesRunningSessions: params.movesRunningSessions
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'accounts.selectCodex',
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types'
|
||||
import { RuntimeAccountController, type RuntimeAccountServices } from './runtime-account-controller'
|
||||
|
||||
// The saved lane key may differ in case from the account's distro, as older selections do.
|
||||
function claudeState(wslAccountId: string | null): ClaudeRateLimitAccountsState {
|
||||
return {
|
||||
activeAccountId: null,
|
||||
activeAccountIdsByRuntime: { host: null, wsl: { ubuntu: wslAccountId } },
|
||||
accounts: [
|
||||
{
|
||||
id: 'wsl-b',
|
||||
email: 'b@example.com',
|
||||
managedAuthRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
authMethod: 'subscription-oauth',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function controllerWithSwitch(): {
|
||||
accounts: RuntimeAccountController
|
||||
onClaudeSelected: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const onClaudeSelected = vi.fn()
|
||||
const accounts = new RuntimeAccountController(() => ({
|
||||
claudeAccountSelected: onClaudeSelected
|
||||
}))
|
||||
const claudeAccounts = {
|
||||
listAccounts: () => claudeState(null),
|
||||
selectAccount: async (accountId: string | null) => claudeState(accountId)
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: selectClaude reads only these two methods.
|
||||
accounts.setServices({ claudeAccounts } as unknown as RuntimeAccountServices)
|
||||
return { accounts, onClaudeSelected }
|
||||
}
|
||||
|
||||
describe('RuntimeAccountController.selectClaude', () => {
|
||||
it("tells the window about a phone switch, on the selected account's lane", async () => {
|
||||
const { accounts, onClaudeSelected } = controllerWithSwitch()
|
||||
await accounts.selectClaude('wsl-b')
|
||||
expect(onClaudeSelected).toHaveBeenCalledWith({
|
||||
target: { runtime: 'wsl', wslDistro: 'Ubuntu' },
|
||||
before: { state: claudeState(null), accountId: null },
|
||||
after: { state: claudeState('wsl-b'), accountId: 'wsl-b' }
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a web client switch to the web client, which moves its own sessions', async () => {
|
||||
const { accounts, onClaudeSelected } = controllerWithSwitch()
|
||||
await accounts.selectClaude('wsl-b', { movesRunningSessions: true })
|
||||
expect(onClaudeSelected).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -23,13 +23,18 @@ import type {
|
||||
import type { CodexRateLimitResetOutcome, RateLimitState } from '../../shared/rate-limit-types'
|
||||
import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope'
|
||||
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
|
||||
import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection'
|
||||
import {
|
||||
getClaudeSelectionTargetForAccount,
|
||||
getSelectedClaudeAccountIdForTarget,
|
||||
type ClaudeAccountSelectionTarget
|
||||
} from '../claude-accounts/runtime-selection'
|
||||
import {
|
||||
hostAccountsManagedByDesktopMessage,
|
||||
type HostAccountLaunchEnv
|
||||
} from '../../shared/host-account-launch-env'
|
||||
import type { HostClaudeProfileRefresh } from './host-claude-profile-refresh'
|
||||
import { HostAccountLaunchEnvState } from './host-account-launch-env-state'
|
||||
import type { RuntimeNotifier } from './runtime-notifier-contract'
|
||||
|
||||
export type RuntimeAccountServices = {
|
||||
claudeAccounts: ClaudeAccountService
|
||||
@@ -57,11 +62,15 @@ export type CodexRateLimitResetRpcResult = {
|
||||
}
|
||||
)
|
||||
|
||||
type ClaudeSelectionNotifier = Pick<RuntimeNotifier, 'claudeAccountSelected'>
|
||||
|
||||
export class RuntimeAccountController {
|
||||
private services: RuntimeAccountServices | null = null
|
||||
private commitMessageAgentEnvironment: CommitMessageAgentEnvironmentResolvers | null = null
|
||||
private readonly hostAccounts = new HostAccountLaunchEnvState(() => this.services !== null)
|
||||
|
||||
constructor(private readonly getNotifier: () => ClaudeSelectionNotifier | null = () => null) {}
|
||||
|
||||
setServices(services: RuntimeAccountServices): void {
|
||||
this.services = services
|
||||
}
|
||||
@@ -195,8 +204,27 @@ export class RuntimeAccountController {
|
||||
])
|
||||
}
|
||||
|
||||
selectClaude(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
|
||||
return this.requireServices().claudeAccounts.selectAccount(accountId)
|
||||
/** Desktop switches go through IPC and handle their own sessions; this path tells the window. */
|
||||
async selectClaude(
|
||||
accountId: string | null,
|
||||
options: { movesRunningSessions?: true } = {}
|
||||
): Promise<ClaudeRateLimitAccountsState> {
|
||||
const { claudeAccounts } = this.requireServices()
|
||||
if (options.movesRunningSessions) {
|
||||
return claudeAccounts.selectAccount(accountId)
|
||||
}
|
||||
const before = claudeAccounts.listAccounts()
|
||||
const after = await claudeAccounts.selectAccount(accountId)
|
||||
const account = before.accounts.find((candidate) => candidate.id === accountId)
|
||||
const target: ClaudeAccountSelectionTarget = account
|
||||
? getClaudeSelectionTargetForAccount(account)
|
||||
: { runtime: 'host' }
|
||||
this.getNotifier()?.claudeAccountSelected?.({
|
||||
target,
|
||||
before: { state: before, accountId: selectedClaudeAccountId(before, target) },
|
||||
after: { state: after, accountId: selectedClaudeAccountId(after, target) }
|
||||
})
|
||||
return after
|
||||
}
|
||||
|
||||
selectCodex(accountId: string | null): Promise<CodexRateLimitAccountsState> {
|
||||
@@ -303,3 +331,16 @@ export class RuntimeAccountController {
|
||||
return this.services
|
||||
}
|
||||
}
|
||||
|
||||
function selectedClaudeAccountId(
|
||||
state: ClaudeRateLimitAccountsState,
|
||||
target: ClaudeAccountSelectionTarget
|
||||
): string | null {
|
||||
return getSelectedClaudeAccountIdForTarget(
|
||||
{
|
||||
activeClaudeManagedAccountId: state.activeAccountId,
|
||||
activeClaudeManagedAccountIdsByRuntime: state.activeAccountIdsByRuntime
|
||||
},
|
||||
target
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
RuntimeEditorTabReveal,
|
||||
RuntimeFileOpenPosition
|
||||
} from '../../shared/runtime-file-contracts'
|
||||
import type { ClaudeAccountSelectionChange } from '../../shared/managed-account-types'
|
||||
import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict'
|
||||
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
|
||||
import type { PaneLaunchRecipe } from '../../shared/terminal-attachment-launch-result'
|
||||
@@ -180,4 +181,5 @@ export type RuntimeNotifier = {
|
||||
browserDriverChanged?(browserPageId: string, driver: RuntimeBrowserDriverState): void
|
||||
browserRemoteViewersChanged?(browserPageId: string, hasRemoteViewers: boolean): void
|
||||
clientHostedBrowserRowsChanged?(event: ClientHostedBrowserRowsEvent): void
|
||||
claudeAccountSelected?(change: ClaudeAccountSelectionChange): void
|
||||
}
|
||||
|
||||
@@ -621,7 +621,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
error: expect.objectContaining({ code: 'forbidden' })
|
||||
})
|
||||
)
|
||||
expect(mocks.selectClaudeAccount).toHaveBeenCalledWith('claude-account')
|
||||
expect(mocks.selectClaudeAccount).toHaveBeenCalledWith('claude-account', {})
|
||||
expect(mocks.selectCodexAccount).toHaveBeenCalledWith(null)
|
||||
expect(mocks.consumeCodexRateLimitResetCredit).toHaveBeenCalledWith(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
|
||||
@@ -227,7 +227,9 @@ export function registerRuntimeWindowLifecycle(
|
||||
send('runtime:browserDriverChanged', { browserPageId, driver }),
|
||||
browserRemoteViewersChanged: (browserPageId, hasRemoteViewers) =>
|
||||
send('runtime:browserRemoteViewersChanged', { browserPageId, hasRemoteViewers }),
|
||||
clientHostedBrowserRowsChanged: (event) => send('runtime:clientHostedBrowserRowsChanged', event)
|
||||
clientHostedBrowserRowsChanged: (event) =>
|
||||
send('runtime:clientHostedBrowserRowsChanged', event),
|
||||
claudeAccountSelected: (change) => send('claudeAccounts:selectedElsewhere', change)
|
||||
})
|
||||
// Switch point: the window reads its sidebar from main's feed and re-reads on each notice.
|
||||
const detachSidebarInputsNotices = ORCA_SIDEBAR_INPUTS_IS_MAIN
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ClaudeAccountSelectionChange,
|
||||
ClaudeRateLimitAccountsState,
|
||||
CodexRateLimitAccountsState
|
||||
} from '../../shared/managed-account-types'
|
||||
@@ -62,6 +63,7 @@ export type ClaudeAccountsApi = {
|
||||
runtime?: 'host' | 'wsl'
|
||||
wslDistro?: string | null
|
||||
}) => Promise<ClaudeRateLimitAccountsState>
|
||||
onSelectedElsewhere?: (callback: (change: ClaudeAccountSelectionChange) => void) => () => void
|
||||
}
|
||||
|
||||
export type GrokAccountsApi = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
import type { ClaudeAccountSelectionChange } from '../../shared/managed-account-types'
|
||||
import type { PreloadApi } from '../api-types'
|
||||
|
||||
export const claudeAccountsApi = {
|
||||
@@ -16,5 +17,11 @@ export const claudeAccountsApi = {
|
||||
accountId: string | null
|
||||
runtime?: 'host' | 'wsl'
|
||||
wslDistro?: string | null
|
||||
}) => ipcRenderer.invoke('claudeAccounts:select', args)
|
||||
}) => ipcRenderer.invoke('claudeAccounts:select', args),
|
||||
onSelectedElsewhere: (callback: (change: ClaudeAccountSelectionChange) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, change: ClaudeAccountSelectionChange) =>
|
||||
callback(change)
|
||||
ipcRenderer.on('claudeAccounts:selectedElsewhere', listener)
|
||||
return () => ipcRenderer.removeListener('claudeAccounts:selectedElsewhere', listener)
|
||||
}
|
||||
} satisfies PreloadApi['claudeAccounts']
|
||||
|
||||
@@ -24,6 +24,7 @@ import { registerPreflightWslRetryIpcBridge } from './preflight-wsl-retry-ipc-br
|
||||
import { registerOsMarkdownFileOpenBridge } from './os-markdown-file-open-bridge'
|
||||
import { registerProjectCatalogIpcBridge } from './project-catalog-ipc-bridge'
|
||||
import { registerRateLimitIpcBridge } from './rate-limit-ipc-bridge'
|
||||
import { registerClaudeAccountSwitchIpcBridge } from './claude-account-switch-ipc-bridge'
|
||||
import { registerRuntimeClientIpcBridge } from './runtime-client-ipc-bridge'
|
||||
import { registerSessionTabIpcBridge } from './session-tab-ipc-bridge'
|
||||
import { registerSettingsAndSidebarIpcBridge } from './settings-sidebar-ipc-bridge'
|
||||
@@ -136,6 +137,7 @@ export function installAppLifetimeIpcEvents(): () => void {
|
||||
registerBrowserTabActivateIpcBridge(unsubs)
|
||||
registerTabLifecycleIpcBridge(unsubs)
|
||||
registerRateLimitIpcBridge(unsubs)
|
||||
registerClaudeAccountSwitchIpcBridge(unsubs)
|
||||
const stopDirectSshStateBridge = registerDirectSshStateIpcBridge(unsubs)
|
||||
registerZoomIpcBridge(unsubs)
|
||||
const agentStatusBridge = registerAgentStatusIpcBridge(unsubs)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { handleRunningClaudeSessionsAfterSwitch } from '@/lib/claude-account-switch-sessions'
|
||||
|
||||
export function registerClaudeAccountSwitchIpcBridge(unsubs: (() => void)[]): void {
|
||||
const subscribe = window.api.claudeAccounts?.onSelectedElsewhere
|
||||
if (!subscribe) {
|
||||
return
|
||||
}
|
||||
unsubs.push(
|
||||
subscribe((change) => {
|
||||
void handleRunningClaudeSessionsAfterSwitch({ owner: { kind: 'local' }, ...change })
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -133,7 +133,8 @@ export function createClaudeAccountsApi(): PreloadApi['claudeAccounts'] {
|
||||
}
|
||||
return callRuntimeResult<ClaudeRateLimitAccountsState>(
|
||||
'accounts.selectClaude',
|
||||
{ accountId },
|
||||
// This window's switcher moves running sessions itself; the host window must not too.
|
||||
{ accountId, movesRunningSessions: true },
|
||||
ACCOUNT_MUTATION_TIMEOUT_MS
|
||||
)
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('web rate limits and accounts preload API', () => {
|
||||
await expect(api.claudeAccounts.select({ accountId: 'account-b' })).resolves.toEqual(selected)
|
||||
expect(calls).toContainEqual({
|
||||
method: 'accounts.selectClaude',
|
||||
params: { accountId: 'account-b' }
|
||||
params: { accountId: 'account-b', movesRunningSessions: true }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { CodexAccountSelectionTarget } from './codex-selection-lane'
|
||||
|
||||
export type ManagedDataAccountProvider = 'opencode' | 'devin'
|
||||
|
||||
export type ManagedDataAccountSummary = {
|
||||
@@ -128,6 +130,13 @@ export type ClaudeRateLimitAccountsState = {
|
||||
systemDefaultMayBeCopied?: boolean
|
||||
}
|
||||
|
||||
/** A Claude switch made over runtime RPC, e.g. from the phone. */
|
||||
export type ClaudeAccountSelectionChange = {
|
||||
target: CodexAccountSelectionTarget
|
||||
before: { state: ClaudeRateLimitAccountsState; accountId: string | null }
|
||||
after: { state: ClaudeRateLimitAccountsState; accountId: string | null }
|
||||
}
|
||||
|
||||
export type ClaudeManagedAccountRuntimeSelection = {
|
||||
host: string | null
|
||||
wsl: Record<string, string | null>
|
||||
|
||||
@@ -37,6 +37,11 @@ export const SelectAccountParams = z.object({
|
||||
.transform((v) => (v === null ? null : v))
|
||||
})
|
||||
|
||||
export const SelectClaudeAccountParams = SelectAccountParams.extend({
|
||||
// The web client moves running sessions in its own window; older hosts strip this.
|
||||
movesRunningSessions: z.literal(true).optional()
|
||||
})
|
||||
|
||||
export const SelectCodexAccountForTargetParams = SelectAccountParams.extend({
|
||||
target: CodexSelectionTarget
|
||||
})
|
||||
|
||||
+2
-1
@@ -59,6 +59,7 @@ import {
|
||||
RemoveAccountParams,
|
||||
RemoveDataAccountParams,
|
||||
SelectAccountParams,
|
||||
SelectClaudeAccountParams,
|
||||
SelectCodexAccountForTargetParams,
|
||||
SelectDataAccountParams
|
||||
} from './accounts-params'
|
||||
@@ -746,7 +747,7 @@ export const RPC_PARAMS_BY_METHOD = {
|
||||
'accounts.removeClaude': RemoveAccountParams,
|
||||
'accounts.removeCodex': RemoveAccountParams,
|
||||
'accounts.removeData': RemoveDataAccountParams,
|
||||
'accounts.selectClaude': SelectAccountParams,
|
||||
'accounts.selectClaude': SelectClaudeAccountParams,
|
||||
'accounts.selectCodex': SelectAccountParams,
|
||||
'accounts.selectCodexForTarget': SelectCodexAccountForTargetParams,
|
||||
'accounts.selectData': SelectDataAccountParams,
|
||||
|
||||
Reference in New Issue
Block a user