From d3a597e06b2b0dc563249cf4400735817f31e50d Mon Sep 17 00:00:00 2001 From: Lyubomir Nanev Date: Mon, 22 Jun 2026 16:21:47 +0300 Subject: [PATCH 1/5] Add side-panel overlay view support for Chrome side panel mode --- src/controllers/dapps/dapps.ts | 9 ++++++--- src/controllers/main/main.ts | 4 ++-- src/controllers/transfer/transfer.ts | 5 +++-- src/controllers/ui/ui.ts | 12 +++++++----- src/interfaces/storage.ts | 1 + src/interfaces/ui.ts | 5 ++++- 6 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/controllers/dapps/dapps.ts b/src/controllers/dapps/dapps.ts index f837a65110..82385c161e 100644 --- a/src/controllers/dapps/dapps.ts +++ b/src/controllers/dapps/dapps.ts @@ -35,7 +35,7 @@ import { Messenger } from '../../interfaces/messenger' import { INetworksController } from '../../interfaces/network' import { BlacklistedStatus, IPhishingController } from '../../interfaces/phishing' import { IStorageController } from '../../interfaces/storage' -import { IUiController, View } from '../../interfaces/ui' +import { IUiController, View, isExtensionOverlayView } from '../../interfaces/ui' import { UserRequest } from '../../interfaces/userRequest' import { formatDappName, @@ -164,7 +164,7 @@ export class DappsController extends EventEmitter implements IDappsController { this.#ui.uiEvent.on('removeView', (removedView: View) => { if ( - removedView.type === 'popup' && + isExtensionOverlayView(removedView) && this.#shouldRetryFetchAndUpdate && this.#retryFetchAndUpdateAttempts < this.#retryFetchAndUpdateMaxAttempts ) { @@ -271,7 +271,10 @@ export class DappsController extends EventEmitter implements IDappsController { this.#shouldRetryFetchAndUpdate = true // run the interval if the initial fetch failed while the extension is not in use - if (!this.#retryFetchAndUpdateAttempts && !this.#ui.views.some((v) => v.type === 'popup')) { + if ( + !this.#retryFetchAndUpdateAttempts && + !this.#ui.views.some((v) => isExtensionOverlayView(v)) + ) { this.#retryFetchAndUpdateInterval.start() } else { this.#retryFetchAndUpdateInterval.stop() diff --git a/src/controllers/main/main.ts b/src/controllers/main/main.ts index dbedff045e..b6e48633bd 100644 --- a/src/controllers/main/main.ts +++ b/src/controllers/main/main.ts @@ -85,7 +85,7 @@ import { ISwapAndBridgeController, SwapAndBridgeActiveRoute } from '@/interfaces import { ITransactionManagerController } from '@/interfaces/transactionManager' import { ITransferController } from '@/interfaces/transfer' import { ITransfersScannerController } from '@/interfaces/transferScanner' -import { IUiController, UiManager, View } from '@/interfaces/ui' +import { IUiController, UiManager, View, isExtensionOverlayView } from '@/interfaces/ui' import { BenzinUserRequest, CallsUserRequest } from '@/interfaces/userRequest' import { getDefaultSelectedAccount } from '@/libs/account/account' import { AccountOp } from '@/libs/accountOp/accountOp' @@ -696,7 +696,7 @@ export class MainController extends EventEmitter implements IMainController { }) this.ui.uiEvent.on('addView', async (view: View) => { - if (view.type === 'popup') await this.onPopupOpen(view.id) + if (isExtensionOverlayView(view)) await this.onPopupOpen(view.id) }) } diff --git a/src/controllers/transfer/transfer.ts b/src/controllers/transfer/transfer.ts index fcc10672d2..a526e2b1a8 100644 --- a/src/controllers/transfer/transfer.ts +++ b/src/controllers/transfer/transfer.ts @@ -22,7 +22,7 @@ import { ITransferController, TransferUpdate } from '../../interfaces/transfer' -import { IUiController, View } from '../../interfaces/ui' +import { IUiController, View, isExtensionOverlayView } from '../../interfaces/ui' import { getBaseAccount } from '../../libs/account/getBaseAccount' import { AccountOp } from '../../libs/accountOp/accountOp' import { Call } from '../../libs/accountOp/types' @@ -1203,7 +1203,8 @@ export class TransferController extends EventEmitter implements ITransferControl // Always reset the session id this.#currentTransferSessionId = null - if (this.hasPersistedState && !isNavigateOut && viewType === 'popup') return + if (this.hasPersistedState && !isNavigateOut && isExtensionOverlayView({ type: viewType })) + return this.reset({ destroyAccountOp: true }) } diff --git a/src/controllers/ui/ui.ts b/src/controllers/ui/ui.ts index c11b727b01..7333a457a6 100644 --- a/src/controllers/ui/ui.ts +++ b/src/controllers/ui/ui.ts @@ -1,7 +1,7 @@ import { EventEmitter as UiEventEmitter } from 'events' import { IEventEmitterRegistryController } from '../../interfaces/eventEmitter' -import { IUiController, UiManager, View } from '../../interfaces/ui' +import { IUiController, UiManager, View, isExtensionOverlayView } from '../../interfaces/ui' import EventEmitter from '../eventEmitter/eventEmitter' export class UiController extends EventEmitter implements IUiController { @@ -31,11 +31,13 @@ export class UiController extends EventEmitter implements IUiController { } addView(view: View) { - const existingPopup = this.views.find((v) => v.type === 'popup') + const existingOverlay = this.views.find((v) => isExtensionOverlayView(v)) - // if a popup already exists, just update its id and stop here - if (view.type === 'popup' && existingPopup) { - existingPopup.id = view.id + // if an overlay view already exists, just update its id and stop here + if (isExtensionOverlayView(view) && existingOverlay) { + existingOverlay.id = view.id + existingOverlay.type = view.type + if (!existingOverlay.isReady) this.uiEvent.emit('addView', view) this.emitUpdate() return } diff --git a/src/interfaces/storage.ts b/src/interfaces/storage.ts index 0eec65ccbd..615e32d0d3 100644 --- a/src/interfaces/storage.ts +++ b/src/interfaces/storage.ts @@ -94,6 +94,7 @@ export type StorageProps = { lastDappsUpdateVersion: string | null isPinned: boolean isPrivacyModeEnabled: boolean + isSidePanelModeEnabled: boolean phishing: { version: number updatedAt: number diff --git a/src/interfaces/ui.ts b/src/interfaces/ui.ts index 88ba4000ac..3f8691d332 100644 --- a/src/interfaces/ui.ts +++ b/src/interfaces/ui.ts @@ -8,13 +8,16 @@ export type IUiController = ControllerInterface< export type View = { id: string - type: 'request-window' | 'tab' | 'popup' | 'mobile' + type: 'request-window' | 'tab' | 'popup' | 'side-panel' | 'mobile' currentRoute?: string previousRoute?: string isReady?: boolean searchParams?: { [key: string]: string } } +export const isExtensionOverlayView = (view: Pick) => + view.type === 'popup' || view.type === 'side-panel' + export type UiManager = { window: { event: EventEmitter From 3f43a7e778cdaea919b6e9ec1413e798b674d605 Mon Sep 17 00:00:00 2001 From: Lyubomir Nanev Date: Thu, 2 Jul 2026 10:11:55 +0300 Subject: [PATCH 2/5] Added dismissActiveRequest and handleRequestWindowClose in requests.ts --- src/controllers/requests/requests.ts | 130 +++++++++++++++++---------- 1 file changed, 84 insertions(+), 46 deletions(-) diff --git a/src/controllers/requests/requests.ts b/src/controllers/requests/requests.ts index 4147cbc8ec..a2a5eb252a 100644 --- a/src/controllers/requests/requests.ts +++ b/src/controllers/requests/requests.ts @@ -593,10 +593,24 @@ export class RequestsController extends EventEmitter implements IRequestsControl await this.closeRequestWindow() } + // When the wallet runs in the Chrome side panel and the panel is open, the action + // requests are rendered inside the panel itself (over the dashboard) instead of a + // separate request window. A side-panel view only exists in `views` while the panel + // is open, so its presence is the signal to skip the window entirely. + get #isSidePanelOpen() { + return this.#ui.views.some((view) => view.type === 'side-panel') + } + async openRequestWindow(params?: OpenRequestWindowParams) { const { skipFocus, baseWindowId } = params || {} await this.#awaitPendingPromises() + if (this.#isSidePanelOpen) { + // The side panel reacts to `currentUserRequest` and renders the request in-place. + await this.forceEmitUpdate() + return + } + if (this.requestWindow.windowProps) { if (!skipFocus) { // Force-emitting here updates currentUserRequest on the FE before the window regains focus, @@ -638,6 +652,12 @@ export class RequestsController extends EventEmitter implements IRequestsControl async focusRequestWindow(params?: FocusWindowParams) { await this.#awaitPendingPromises() + // In side-panel mode there is no separate window to focus; the panel already shows it. + if (this.#isSidePanelOpen) { + await this.forceEmitUpdate() + return + } + if ( !this.visibleUserRequests.length || !this.currentUserRequest || @@ -688,63 +708,81 @@ export class RequestsController extends EventEmitter implements IRequestsControl await this.#handleRequestWindowClose(this.requestWindow.windowProps.id) } - async #handleRequestWindowClose(winId: number) { - if ( - winId === this.requestWindow.windowProps?.id || - (!this.visibleUserRequests.length && - this.currentUserRequest && - this.requestWindow.windowProps) - ) { - // Snapshot IDs synchronously before any awaits so requests that arrive - // during async operations below are not incorrectly bulk-rejected. - const requestIdsSnapshotAtClose = new Set(this.userRequests.map((r) => r.id)) + async #onActiveRequestDismissed() { + const requestIdsSnapshotAtClose = new Set(this.userRequests.map((r) => r.id)) - this.requestWindow.windowProps = null - this.requestWindow.loaded = false - this.requestWindow.pendingMessage = null - await this.#setCurrentUserRequest(null) + this.requestWindow.windowProps = null + this.requestWindow.loaded = false + this.requestWindow.pendingMessage = null + await this.#setCurrentUserRequest(null) - const callsCount = this.visibleUserRequests.reduce((acc, request) => { - if (request.kind !== 'calls') return acc + const callsCount = this.visibleUserRequests.reduce((acc, request) => { + if (request.kind !== 'calls') return acc - return acc + (request.signAccountOp.accountOp.calls?.length || 0) - }, 0) + return acc + (request.signAccountOp.accountOp.calls?.length || 0) + }, 0) - if (callsCount) { - await this.#ui.notification.create({ - title: callsCount > 1 ? `${callsCount} transactions queued` : 'Transaction queued', - message: 'Queued pending transactions are available on your Dashboard.' - }) - } + if (callsCount) { + await this.#ui.notification.create({ + title: callsCount > 1 ? `${callsCount} transactions queued` : 'Transaction queued', + message: 'Queued pending transactions are available on your Dashboard.' + }) + } - for (const r of this.userRequests) { - if (r.kind === 'walletAddEthereumChain') { - const chainId = r.meta.params[0].chainId + for (const r of this.userRequests) { + if (r.kind === 'walletAddEthereumChain') { + const chainId = r.meta.params[0].chainId - if (!chainId) continue + if (!chainId) continue - const network = this.#networks.networks.find((n) => n.chainId === BigInt(chainId)) - if (network && !network.disabled) await this.resolveUserRequest(null, r.id) - } + const network = this.#networks.networks.find((n) => n.chainId === BigInt(chainId)) + if (network && !network.disabled) await this.resolveUserRequest(null, r.id) } + } - const userRequestsToRejectOnWindowClose = this.userRequests.filter( - (r) => r.kind !== 'calls' && !r.meta.keepRequestAlive && requestIdsSnapshotAtClose.has(r.id) - ) + const userRequestsToRejectOnWindowClose = this.userRequests.filter( + (r) => r.kind !== 'calls' && !r.meta.keepRequestAlive && requestIdsSnapshotAtClose.has(r.id) + ) - await this.rejectUserRequests( - ethErrors.provider.userRejectedRequest().message, - userRequestsToRejectOnWindowClose.map((r) => r.id), - // If the user closes a window and non-calls user requests exist, - // the window will reopen with the next request. - // For example: if the user has both a sign message and sign account op request, - // closing the window will reject the sign message request but immediately - // reopen the window for the sign account op request. - { shouldOpenNextRequest: false } - ) + await this.rejectUserRequests( + ethErrors.provider.userRejectedRequest().message, + userRequestsToRejectOnWindowClose.map((r) => r.id), + // If the user closes a window and non-calls user requests exist, + // the window will reopen with the next request. + // For example: if the user has both a sign message and sign account op request, + // closing the window will reject the sign message request but immediately + // reopen the window for the sign account op request. + { shouldOpenNextRequest: false } + ) - this.userRequestsWaitingAccountSwitch = [] - this.emitUpdate() + this.userRequestsWaitingAccountSwitch = [] + this.emitUpdate() + } + + async dismissActiveRequest() { + await this.#awaitPendingPromises() + if (!this.currentUserRequest) return + + // In the side panel there is no window to close; apply the same queue semantics as + // closing the request window (keep calls queued, clear the active request). + if (this.#isSidePanelOpen) { + await this.#onActiveRequestDismissed() + return + } + + if (this.requestWindow.windowProps) { + await this.closeRequestWindow() + } + } + + async #handleRequestWindowClose(winId: number) { + if ( + winId === this.requestWindow.windowProps?.id || + (!this.visibleUserRequests.length && + this.currentUserRequest && + this.requestWindow.windowProps) + ) { + await this.#onActiveRequestDismissed() } } From b9f17079bf0c99e940e00c654204919fd99ad508 Mon Sep 17 00:00:00 2001 From: Lyubomir Nanev Date: Fri, 3 Jul 2026 13:48:58 +0300 Subject: [PATCH 3/5] dismissActiveRequest() is now wrapped in try/catch with emitError --- src/controllers/requests/requests.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/controllers/requests/requests.ts b/src/controllers/requests/requests.ts index a2a5eb252a..42cb30218e 100644 --- a/src/controllers/requests/requests.ts +++ b/src/controllers/requests/requests.ts @@ -763,15 +763,23 @@ export class RequestsController extends EventEmitter implements IRequestsControl await this.#awaitPendingPromises() if (!this.currentUserRequest) return - // In the side panel there is no window to close; apply the same queue semantics as - // closing the request window (keep calls queued, clear the active request). - if (this.#isSidePanelOpen) { - await this.#onActiveRequestDismissed() - return - } + try { + // In the side panel there is no window to close; apply the same queue semantics as + // closing the request window (keep calls queued, clear the active request). + if (this.#isSidePanelOpen) { + await this.#onActiveRequestDismissed() + return + } - if (this.requestWindow.windowProps) { - await this.closeRequestWindow() + if (this.requestWindow.windowProps) { + await this.closeRequestWindow() + } + } catch (err) { + this.emitError({ + message: 'Failed to dismiss the active request. Please try again.', + level: 'major', + error: err as Error + }) } } From f1682ac45344dbf1d7181bfad24b60219d7f0a18 Mon Sep 17 00:00:00 2001 From: Lyubomir Nanev Date: Mon, 13 Jul 2026 10:19:22 +0300 Subject: [PATCH 4/5] added dispatchDappTabFocus to ui.ts --- src/controllers/ui/ui.ts | 3 +++ src/interfaces/ui.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/src/controllers/ui/ui.ts b/src/controllers/ui/ui.ts index 7333a457a6..f96f02c8e0 100644 --- a/src/controllers/ui/ui.ts +++ b/src/controllers/ui/ui.ts @@ -15,6 +15,8 @@ export class UiController extends EventEmitter implements IUiController { message: UiManager['message'] + dispatchDappTabFocus?: UiManager['dispatchDappTabFocus'] + constructor({ eventEmitterRegistry, uiManager @@ -28,6 +30,7 @@ export class UiController extends EventEmitter implements IUiController { this.window = uiManager.window this.notification = uiManager.notification this.message = uiManager.message + this.dispatchDappTabFocus = uiManager.dispatchDappTabFocus } addView(view: View) { diff --git a/src/interfaces/ui.ts b/src/interfaces/ui.ts index 3f8691d332..1bc8726fb6 100644 --- a/src/interfaces/ui.ts +++ b/src/interfaces/ui.ts @@ -18,6 +18,8 @@ export type View = { export const isExtensionOverlayView = (view: Pick) => view.type === 'popup' || view.type === 'side-panel' +export const isSidePanelView = (view: Pick) => view.type === 'side-panel' + export type UiManager = { window: { event: EventEmitter @@ -53,6 +55,8 @@ export type UiManager = { sendUiMessage: (params: {}) => void sendNavigateMessage: (viewId: string, route: string, params: { [key: string]: any }) => void } + // Extension-only: nudge a dapp tab after side-panel requests so React Query refetches. + dispatchDappTabFocus?: (targets: { tabId: number; windowId?: number }[]) => void } export type WindowId = number From 57c3c66b32e8f2a59443a4d5e4b68e859908998a Mon Sep 17 00:00:00 2001 From: Lyubomir Nanev Date: Mon, 13 Jul 2026 13:17:43 +0300 Subject: [PATCH 5/5] reset transfer and swap state when the side panel closes --- .../swapAndBridge/swapAndBridge.ts | 13 ++++++ src/controllers/transfer/transfer.test.ts | 40 +++++++++++++++++++ src/controllers/transfer/transfer.ts | 9 +++-- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/controllers/swapAndBridge/swapAndBridge.ts b/src/controllers/swapAndBridge/swapAndBridge.ts index 85b439c7c0..770905680d 100644 --- a/src/controllers/swapAndBridge/swapAndBridge.ts +++ b/src/controllers/swapAndBridge/swapAndBridge.ts @@ -521,6 +521,19 @@ export class SwapAndBridgeController extends EventEmitter implements ISwapAndBri }) this.#ui.uiEvent.on('removeView', (view: View) => { + if (view.type === 'side-panel') { + if (isSwapAndBridge(view.currentRoute)) { + this.#isOnSwapAndBridgeRoute = false + this.updateQuoteInterval.stop() + } + + if (this.sessionIds.includes('side-panel')) { + this.unloadScreen('side-panel', true) + } + + return + } + if (!isSwapAndBridge(view.currentRoute)) return this.#isOnSwapAndBridgeRoute = false this.updateQuoteInterval.stop() diff --git a/src/controllers/transfer/transfer.test.ts b/src/controllers/transfer/transfer.test.ts index f1e3c91930..294d377b3a 100644 --- a/src/controllers/transfer/transfer.test.ts +++ b/src/controllers/transfer/transfer.test.ts @@ -502,6 +502,46 @@ describe('Transfer Controller defaults logic', () => { expect(transferController.areDefaultsSet).toBe(true) }) + test('should reset transfer state on side-panel removeView even when form is persisted', async () => { + const { transferController, uiCtrl, selectedAccountCtrl } = await prepareTest() + + selectedAccountCtrl.portfolio = { + ...selectedAccountCtrl.portfolio, + ...getDefaultPortfolioState() + } + + uiCtrl.addView({ + id: 'side-panel', + type: 'side-panel', + currentRoute: 'dashboard', + isReady: false, + searchParams: {} + }) + uiCtrl.updateView('side-panel', { + currentRoute: 'transfer', + isReady: true, + searchParams: {} + }) + + await transferController.update({ + amount: '1', + addressState: { + fieldValue: PLACEHOLDER_RECIPIENT, + resolvedAddress: '', + resolvedAddressType: null, + isDomainResolving: false + } + }) + + uiCtrl.removeView('side-panel') + + expect(transferController.transferSessionId).toBe(null) + expect(transferController.areDefaultsSet).toBe(false) + expect(transferController.selectedToken).toBeNull() + expect(transferController.amount).toBe('') + expect(transferController.recipientAddress).toBe('') + }) + test('should reset transfer state on popup removeView when form is not persisted', async () => { const { transferController, uiCtrl, selectedAccountCtrl } = await prepareTest() diff --git a/src/controllers/transfer/transfer.ts b/src/controllers/transfer/transfer.ts index a526e2b1a8..9c0573c574 100644 --- a/src/controllers/transfer/transfer.ts +++ b/src/controllers/transfer/transfer.ts @@ -22,7 +22,7 @@ import { ITransferController, TransferUpdate } from '../../interfaces/transfer' -import { IUiController, View, isExtensionOverlayView } from '../../interfaces/ui' +import { IUiController, View } from '../../interfaces/ui' import { getBaseAccount } from '../../libs/account/getBaseAccount' import { AccountOp } from '../../libs/accountOp/accountOp' import { Call } from '../../libs/accountOp/types' @@ -285,7 +285,8 @@ export class TransferController extends EventEmitter implements ITransferControl const isSameMode = this.isTopUp === nextIsTopUp const hasNoSearchParams = Object.keys(searchParams || {}).length === 0 - const shouldKeepExistingForm = isFormInitialized && isSameMode && hasNoSearchParams + const shouldKeepExistingForm = + isFormInitialized && isSameMode && hasNoSearchParams && view.type !== 'side-panel' if (shouldKeepExistingForm) { if (!this.areDefaultsSet) { @@ -1203,8 +1204,8 @@ export class TransferController extends EventEmitter implements ITransferControl // Always reset the session id this.#currentTransferSessionId = null - if (this.hasPersistedState && !isNavigateOut && isExtensionOverlayView({ type: viewType })) - return + // Popup keeps in-progress forms when closed; side panel should start fresh on reopen. + if (this.hasPersistedState && !isNavigateOut && viewType === 'popup') return this.reset({ destroyAccountOp: true }) }