From c9342bcbe3dc9454290a51341f1bf641a121a04a Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 8 Feb 2026 21:49:15 +0800 Subject: [PATCH] fix(ecosystem): fifo miniapp sheets + tx object result --- AGENTS.md | 37 ++++++++ .../src/components/home-button.ts | 24 +++++ src/services/chain-adapter/providers/index.ts | 36 ++++++- .../providers/provider-cache.test.ts | 94 +++++++++++++++++++ src/services/ecosystem/handlers/destroy.ts | 3 +- src/services/ecosystem/handlers/evm.ts | 38 +++++--- src/services/ecosystem/handlers/signing.ts | 13 ++- .../ecosystem/handlers/transaction.ts | 15 +-- src/services/ecosystem/handlers/transfer.ts | 3 +- src/services/ecosystem/handlers/tron.ts | 15 ++- src/services/ecosystem/handlers/wallet.ts | 32 ++++--- src/services/ecosystem/sheet-queue.ts | 39 ++++++++ src/stackflow/components/TabBar.tsx | 2 +- src/test/setup.ts | 4 + 14 files changed, 305 insertions(+), 50 deletions(-) create mode 100644 src/services/chain-adapter/providers/provider-cache.test.ts create mode 100644 src/services/ecosystem/sheet-queue.ts diff --git a/AGENTS.md b/AGENTS.md index caf163fe2..21b092d6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,43 @@ gh pr merge --squash --delete-branch - **严禁主目录开发**: 必须使用 `task start` 创建的 Worktree。 - **Schema-first**: 服务开发必须先定义 `types.ts`。 +--- + +## Agent 工作方式(约定) + +> 目标:避免“围绕 DOM 修修补补”导致的路径爆炸;用可验证的状态机 + 纯函数绑定 DOM。 + +### 1) 状态机优先(State-first) + +- 先把“控制层状态”定义清楚(例如:miniapp 是 `active/backgrounded`,sheet 是否 `pending/visible/resolved`)。 +- 先完成状态迁移/队列/FIFO 等控制逻辑,再把状态映射到 DOM(DOM 只是最后一步渲染)。 +- 使用函数式工具函数做绑定: + - `derive*`:从状态派生视图状态(纯函数) + - `apply*ToDom`:把派生后的视图状态写入 DOM(集中处理,避免到处散落 `style/classList`) + +### 2) KISS:样式控制收敛到单一入口 + +- 不在多个层级叠加 `opacity/visibility/display/pointer-events` 进行互相对冲。 +- 约定由“单一视图状态”驱动: + - `interactive`(是否可交互)→ `pointer-events` + - `visible`(是否可见)→ `opacity` 或 `visibility` + - **禁止**通过移除 DOM 节点/父节点来实现后台化(iframe 被移除会被释放)。 + +### 3) Miniapp Sheet 统一 FIFO(按 appId) + +- 所有需要 UI Sheet 的 handler 必须通过 `enqueueMiniappSheet(appId, task)` 串行化。 +- 目标:同一 app 的敏感动作“先入先出”,且不会被新弹窗打断。 + +### 4) Worktree 与 PR 管理 + +- 一律从 repo 根目录执行 `pnpm agent task start`,避免在 worktree 内再次创建 worktree(会出现嵌套 worktree)。 +- PR 合并后清理无用 worktree/分支,保持本地环境干净可控。 + +### 5) 验证要求 + +- 修改完成后至少跑一次:`pnpm agent review verify`。 +- 对“协议/URL 拼接/缓存”这类易回归问题优先补单测,确保在 CI 中可复现。 + # OpenSpec Instructions diff --git a/packages/ecosystem-native/src/components/home-button.ts b/packages/ecosystem-native/src/components/home-button.ts index b891cc0bc..30e5e40a0 100644 --- a/packages/ecosystem-native/src/components/home-button.ts +++ b/packages/ecosystem-native/src/components/home-button.ts @@ -53,6 +53,8 @@ export class HomeButton extends LitElement { velocityThreshold = 0.3; private swipeDetector = createUpSwipeDetector(); + private ignoreNextClick = false; + private ignoreClickTimeoutId: ReturnType | null = null; override connectedCallback(): void { super.connectedCallback(); @@ -74,6 +76,11 @@ export class HomeButton extends LitElement { } override disconnectedCallback(): void { + if (this.ignoreClickTimeoutId !== null) { + globalThis.clearTimeout(this.ignoreClickTimeoutId); + this.ignoreClickTimeoutId = null; + } + this.removeEventListener('touchstart', this.handleTouchStart, { capture: true }); this.removeEventListener('touchend', this.handleTouchEnd, { capture: true }); this.removeEventListener('touchcancel', this.handleTouchCancel, { capture: true }); @@ -114,6 +121,18 @@ export class HomeButton extends LitElement { if (result.detected && result.direction === 'up') { e.preventDefault(); + + // Some browsers still fire a click after touchend. + // Suppress that tap so swipe-up doesn't immediately trigger the tap path. + this.ignoreNextClick = true; + if (this.ignoreClickTimeoutId !== null) { + globalThis.clearTimeout(this.ignoreClickTimeoutId); + } + this.ignoreClickTimeoutId = globalThis.setTimeout(() => { + this.ignoreNextClick = false; + this.ignoreClickTimeoutId = null; + }, 400); + ecosystemEvents.emit('home:swipe-up', undefined); // Dispatch custom event for React integration @@ -128,6 +147,11 @@ export class HomeButton extends LitElement { }; private handleClick = (): void => { + if (this.ignoreNextClick) { + this.ignoreNextClick = false; + return; + } + ecosystemEvents.emit('home:tap', undefined); this.dispatchEvent( diff --git a/src/services/chain-adapter/providers/index.ts b/src/services/chain-adapter/providers/index.ts index 78843384f..a95849f72 100644 --- a/src/services/chain-adapter/providers/index.ts +++ b/src/services/chain-adapter/providers/index.ts @@ -30,6 +30,7 @@ export { MoralisProviderEffect, createMoralisProviderEffect } from './moralis-pr import type { ApiProvider, ApiProviderFactory } from './types'; import type { ParsedApiEntry } from '@/services/chain-config'; import { chainConfigService } from '@/services/chain-config/service'; +import { chainConfigStore } from '@/stores/chain-config'; import { ChainProvider } from './chain-provider'; import { createEtherscanV1ProviderEffect } from './etherscan-v1-provider.effect'; @@ -97,7 +98,21 @@ export function createChainProvider(chainId: string): ChainProvider { } /** ChainProvider 缓存 */ -const providerCache = new Map(); +type ProviderCacheEntry = { + provider: ChainProvider; + apiKey: string; +}; + +const providerCache = new Map(); + +function createApiKey(entries: ParsedApiEntry[]): string { + return entries + .map((entry) => { + const configKey = entry.config ? JSON.stringify(entry.config) : ""; + return `${entry.type}|${entry.endpoint}|${configKey}`; + }) + .join(","); +} /** * 获取或创建 ChainProvider(带缓存) @@ -105,11 +120,22 @@ const providerCache = new Map(); export function getChainProvider(chainId: string): ChainProvider { const resolvedChainId = resolveChainId(chainId); - let provider = providerCache.get(resolvedChainId); - if (!provider) { - provider = createChainProvider(resolvedChainId); - providerCache.set(resolvedChainId, provider); + // Avoid caching providers before chain configs are initialized. + // Otherwise `entries=[]` would create an empty provider and poison the cache. + if (!chainConfigStore.state.snapshot) { + return createChainProvider(resolvedChainId); + } + + const entries = chainConfigService.getApi(resolvedChainId); + const apiKey = createApiKey(entries); + + const cached = providerCache.get(resolvedChainId); + if (cached && cached.apiKey === apiKey) { + return cached.provider; } + + const provider = createChainProvider(resolvedChainId); + providerCache.set(resolvedChainId, { provider, apiKey }); return provider; } diff --git a/src/services/chain-adapter/providers/provider-cache.test.ts b/src/services/chain-adapter/providers/provider-cache.test.ts new file mode 100644 index 000000000..c662bb7e0 --- /dev/null +++ b/src/services/chain-adapter/providers/provider-cache.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import 'fake-indexeddb/auto' + +import { resetChainConfigStorageForTests } from '@/services/chain-config/storage' +import { chainConfigActions, chainConfigStore } from '@/stores/chain-config' + +import { clearProviderCache, getChainProvider } from './index' + +describe('getChainProvider cache safety', () => { + beforeEach(async () => { + await resetChainConfigStorageForTests() + clearProviderCache() + chainConfigStore.setState(() => ({ + snapshot: null, + isLoading: false, + error: null, + migrationRequired: false, + })) + }) + + it('does not cache an empty provider before chain configs initialize', async () => { + const provider1 = getChainProvider('bfmetav2') + const provider2 = getChainProvider('bfmetav2') + + expect(provider1).not.toBe(provider2) + expect(provider1.supportsFullTransaction).toBe(false) + + await chainConfigActions.initialize() + + const provider3 = getChainProvider('bfmetav2') + const provider4 = getChainProvider('bfmetav2') + + expect(provider3).toBe(provider4) + expect(provider3).not.toBe(provider1) + expect(provider3.supportsFullTransaction).toBe(true) + }) + + it('rebuilds the cached provider when api entries change', async () => { + await chainConfigActions.initialize() + + const originalSnapshot = chainConfigStore.state.snapshot + expect(originalSnapshot).not.toBeNull() + if (!originalSnapshot) return + + const originalConfig = originalSnapshot.configs.find((config) => config.id === 'bfmetav2') + expect(originalConfig).toBeTruthy() + if (!originalConfig) return + + chainConfigStore.setState((state) => { + const snapshot = state.snapshot + if (!snapshot) return state + + return { + ...state, + snapshot: { + ...snapshot, + configs: snapshot.configs.map((config) => + config.id === originalConfig.id + ? { + ...config, + apis: [], + } + : config, + ), + }, + } + }) + + clearProviderCache() + + const provider1 = getChainProvider('bfmetav2') + expect(provider1.supportsFullTransaction).toBe(false) + + chainConfigStore.setState((state) => { + const snapshot = state.snapshot + if (!snapshot) return state + + return { + ...state, + snapshot: { + ...snapshot, + configs: snapshot.configs.map((config) => + config.id === originalConfig.id ? originalConfig : config, + ), + }, + } + }) + + const provider2 = getChainProvider('bfmetav2') + expect(provider2).not.toBe(provider1) + expect(provider2.supportsFullTransaction).toBe(true) + }) +}) + diff --git a/src/services/ecosystem/handlers/destroy.ts b/src/services/ecosystem/handlers/destroy.ts index 558d2dc55..c1ef4fb42 100644 --- a/src/services/ecosystem/handlers/destroy.ts +++ b/src/services/ecosystem/handlers/destroy.ts @@ -5,6 +5,7 @@ import type { MethodHandler, EcosystemDestroyParams } from '../types' import { BioErrorCodes } from '../types' import { HandlerContext, type MiniappInfo, toMiniappInfo } from './context' +import { enqueueMiniappSheet } from '../sheet-queue' // 兼容旧 API let _showDestroyDialog: ((params: EcosystemDestroyParams & { app: MiniappInfo }) => Promise<{ txHash: string } | null>) | null = null @@ -43,7 +44,7 @@ export const handleDestroyAsset: MethodHandler = async (params, context) => { app: toMiniappInfo(context), } - const result = await showDestroyDialog(destroyParams) + const result = await enqueueMiniappSheet(context.appId, () => showDestroyDialog(destroyParams)) if (!result) { throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED }) diff --git a/src/services/ecosystem/handlers/evm.ts b/src/services/ecosystem/handlers/evm.ts index 82400ce7f..6902ed8f7 100644 --- a/src/services/ecosystem/handlers/evm.ts +++ b/src/services/ecosystem/handlers/evm.ts @@ -8,6 +8,7 @@ import type { MethodHandler, BioAccount } from '../types' import { BioErrorCodes } from '../types' import { HandlerContext } from './context' +import { enqueueMiniappSheet } from '../sheet-queue' import { toHexChainId, parseHexChainId, @@ -132,7 +133,10 @@ export const handleEthRequestAccounts: MethodHandler = async (_params, context) } const chainId = getCurrentChainId(context.appId) - const wallet = await showWalletPicker({ chainId, app: { name: context.appName, icon: context.appIcon } }) + + const wallet = await enqueueMiniappSheet(context.appId, () => + showWalletPicker({ chainId, app: { name: context.appName, icon: context.appIcon } }), + ) if (!wallet) { throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED }) } @@ -173,12 +177,14 @@ export const handleSwitchChain: MethodHandler = async (params, context) => { // Show confirmation dialog if (_showChainSwitchConfirm) { - const approved = await _showChainSwitchConfirm({ - fromChainId: currentChainId, - toChainId: targetChainId, - appName: context.appName, - appIcon: context.appIcon, - }) + const approved = await enqueueMiniappSheet(context.appId, () => + _showChainSwitchConfirm({ + fromChainId: currentChainId, + toChainId: targetChainId, + appName: context.appName, + appIcon: context.appIcon, + }), + ) if (!approved) { throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED }) } @@ -211,7 +217,9 @@ export const handlePersonalSign: MethodHandler = async (params, context) => { throw Object.assign(new Error('Signing dialog not available'), { code: BioErrorCodes.INTERNAL_ERROR }) } - const result = await showSigningDialog({ message, address, appName: context.appName }) + const result = await enqueueMiniappSheet(context.appId, () => + showSigningDialog({ message, address, appName: context.appName }), + ) if (!result) { throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED }) } @@ -243,11 +251,13 @@ export const handleSignTypedDataV4: MethodHandler = async (params, context) => { // Format typed data for display const displayMessage = JSON.stringify(data, null, 2) - const result = await showSigningDialog({ - message: displayMessage, - address, - appName: context.appName, - }) + const result = await enqueueMiniappSheet(context.appId, () => + showSigningDialog({ + message: displayMessage, + address, + appName: context.appName, + }), + ) if (!result) { throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED }) } @@ -272,7 +282,7 @@ export const handleEthSendTransaction: MethodHandler = async (params, context) = tx.chainId = getCurrentChainId(context.appId) } - const result = await showTransactionDialog({ tx, appName: context.appName }) + const result = await enqueueMiniappSheet(context.appId, () => showTransactionDialog({ tx, appName: context.appName })) if (!result) { throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED }) } diff --git a/src/services/ecosystem/handlers/signing.ts b/src/services/ecosystem/handlers/signing.ts index f45190835..070e39c8b 100644 --- a/src/services/ecosystem/handlers/signing.ts +++ b/src/services/ecosystem/handlers/signing.ts @@ -5,6 +5,7 @@ import type { MethodHandler } from '../types' import { BioErrorCodes } from '../types' import { HandlerContext, type SigningParams, type SigningResult, toMiniappInfo } from './context' +import { enqueueMiniappSheet } from '../sheet-queue' // 兼容旧 API(现在返回 SigningResult) let _showSigningDialog: ((params: SigningParams) => Promise) | null = null @@ -32,12 +33,14 @@ export const handleSignMessage: MethodHandler = async (params, context) => { throw Object.assign(new Error('Signing dialog not available'), { code: BioErrorCodes.INTERNAL_ERROR }) } - const result = await showSigningDialog({ + const dialogParams = { message: opts.message, address: opts.address, chainName: opts.chainName, app: toMiniappInfo(context), - }) + } + + const result = await enqueueMiniappSheet(context.appId, () => showSigningDialog(dialogParams)) if (!result) { throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED }) @@ -62,12 +65,14 @@ export const handleSignTypedData: MethodHandler = async (params, context) => { // Convert typed data to readable message const message = JSON.stringify(opts.data, null, 2) - const result = await showSigningDialog({ + const dialogParams = { message, address: opts.address, chainName: opts.chainName, app: toMiniappInfo(context), - }) + } + + const result = await enqueueMiniappSheet(context.appId, () => showSigningDialog(dialogParams)) if (!result) { throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED }) diff --git a/src/services/ecosystem/handlers/transaction.ts b/src/services/ecosystem/handlers/transaction.ts index baff36d05..69c04685b 100644 --- a/src/services/ecosystem/handlers/transaction.ts +++ b/src/services/ecosystem/handlers/transaction.ts @@ -8,6 +8,7 @@ import type { MethodHandler, EcosystemTransferParams, UnsignedTransaction, SignedTransaction } from '../types' import { BioErrorCodes } from '../types' import { HandlerContext, type SignTransactionParams, toMiniappInfo } from './context' +import { enqueueMiniappSheet } from '../sheet-queue' import { Amount } from '@/types/amount' import { chainConfigActions, chainConfigSelectors, chainConfigStore, walletStore } from '@/stores' @@ -166,12 +167,14 @@ export const handleSignTransaction: MethodHandler = async (params, context) => { throw Object.assign(new Error('SignTransaction dialog not available'), { code: BioErrorCodes.INTERNAL_ERROR }) } - const result = await showDialog({ - from: opts.from, - chain: opts.chain, - unsignedTx: opts.unsignedTx, - app: toMiniappInfo(context), - }) + const result = await enqueueMiniappSheet(context.appId, () => + showDialog({ + from: opts.from, + chain: opts.chain, + unsignedTx: opts.unsignedTx, + app: toMiniappInfo(context), + }), + ) if (!result) { throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED }) diff --git a/src/services/ecosystem/handlers/transfer.ts b/src/services/ecosystem/handlers/transfer.ts index b9601962d..c69b796c0 100644 --- a/src/services/ecosystem/handlers/transfer.ts +++ b/src/services/ecosystem/handlers/transfer.ts @@ -5,6 +5,7 @@ import type { MethodHandler, EcosystemTransferParams } from '../types' import { BioErrorCodes } from '../types' import { HandlerContext, type MiniappInfo, type TransferDialogResult, toMiniappInfo } from './context' +import { enqueueMiniappSheet } from '../sheet-queue' // 兼容旧 API let _showTransferDialog: ((params: EcosystemTransferParams & { app: MiniappInfo }) => Promise) | null = null @@ -74,7 +75,7 @@ export const handleSendTransaction: MethodHandler = async (params, context) => { transferParams.tokenAddress = opts.tokenAddress } - const result = await showTransferDialog(transferParams) + const result = await enqueueMiniappSheet(context.appId, () => showTransferDialog(transferParams)) if (!result) { throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED }) diff --git a/src/services/ecosystem/handlers/tron.ts b/src/services/ecosystem/handlers/tron.ts index 437c6c42b..c2a8740e6 100644 --- a/src/services/ecosystem/handlers/tron.ts +++ b/src/services/ecosystem/handlers/tron.ts @@ -8,6 +8,7 @@ import type { MethodHandler, BioAccount } from '../types' import { BioErrorCodes } from '../types' import { HandlerContext, type TronTransaction, type MiniappInfo } from './context' +import { enqueueMiniappSheet } from '../sheet-queue' // Re-export for convenience export type { TronTransaction } from './context' @@ -90,7 +91,9 @@ export const handleTronRequestAccounts: MethodHandler = async (_params, context) throw Object.assign(new Error('Wallet picker not available'), { code: BioErrorCodes.INTERNAL_ERROR }) } - const wallet = await showWalletPicker({ app: { name: context.appName, icon: context.appIcon } }) + const wallet = await enqueueMiniappSheet(context.appId, () => + showWalletPicker({ app: { name: context.appName, icon: context.appIcon } }), + ) if (!wallet) { throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED }) } @@ -140,10 +143,12 @@ export const handleTronSignTransaction: MethodHandler = async (params, context) throw Object.assign(new Error('Signing dialog not available'), { code: BioErrorCodes.INTERNAL_ERROR }) } - const result = await showSigningDialog({ - transaction, - appName: context.appName, - }) + const result = await enqueueMiniappSheet(context.appId, () => + showSigningDialog({ + transaction, + appName: context.appName, + }), + ) if (!result) { throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED }) diff --git a/src/services/ecosystem/handlers/wallet.ts b/src/services/ecosystem/handlers/wallet.ts index 916808fe0..d15c2eb92 100644 --- a/src/services/ecosystem/handlers/wallet.ts +++ b/src/services/ecosystem/handlers/wallet.ts @@ -5,6 +5,7 @@ import type { MethodHandler, BioAccount } from '../types' import { BioErrorCodes } from '../types' import { HandlerContext } from './context' +import { enqueueMiniappSheet } from '../sheet-queue' import { getChainProvider } from '@/services/chain-adapter/providers' import { walletStore } from '@/stores/wallet' @@ -49,10 +50,12 @@ export const handleRequestAccounts: MethodHandler = async (params, context) => { // 支持 chain 参数过滤钱包 const opts = params as { chain?: string } | undefined - const wallet = await showWalletPicker({ - chain: opts?.chain, - app: { name: context.appName, icon: context.appIcon }, - }) + const wallet = await enqueueMiniappSheet(context.appId, () => + showWalletPicker({ + chain: opts?.chain, + app: { name: context.appName, icon: context.appIcon }, + }), + ) if (!wallet) { throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED }) } @@ -77,10 +80,12 @@ export const handleSelectAccount: MethodHandler = async (params, context) => { } const opts = params as { chain?: string } | undefined - const wallet = await showWalletPicker({ - ...opts, - app: { name: context.appName, icon: context.appIcon }, - }) + const wallet = await enqueueMiniappSheet(context.appId, () => + showWalletPicker({ + ...opts, + app: { name: context.appName, icon: context.appIcon }, + }), + ) if (!wallet) { throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED }) } @@ -96,10 +101,12 @@ export const handlePickWallet: MethodHandler = async (params, context) => { } const opts = params as { chain?: string; exclude?: string } | undefined - const account = await showWalletPicker({ - ...opts, - app: { name: context.appName, icon: context.appIcon }, - }) + const account = await enqueueMiniappSheet(context.appId, () => + showWalletPicker({ + ...opts, + app: { name: context.appName, icon: context.appIcon }, + }), + ) if (!account) { throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED }) } @@ -129,4 +136,3 @@ export const handleGetBalance: MethodHandler = async (params, _context) => { return '0' } } - diff --git a/src/services/ecosystem/sheet-queue.ts b/src/services/ecosystem/sheet-queue.ts new file mode 100644 index 000000000..7ad5ccb3a --- /dev/null +++ b/src/services/ecosystem/sheet-queue.ts @@ -0,0 +1,39 @@ +type SheetTask = () => Promise + +const tails = new Map>() + +/** + * Enqueue a UI sheet task for a miniapp. + * + * - FIFO per appId + * - Each task waits for the previous one to settle + * - Rejections do not break the queue + */ +export function enqueueMiniappSheet(appId: string, task: SheetTask): Promise { + const previous = tails.get(appId) ?? Promise.resolve() + + const run = previous + .catch(() => undefined) + .then(task) + + const nextTail = run.then( + () => undefined, + () => undefined, + ) + + tails.set(appId, nextTail) + + void nextTail.finally(() => { + if (tails.get(appId) === nextTail) { + tails.delete(appId) + } + }) + + return run +} + +/** @internal For tests only. */ +export function __clearMiniappSheetQueueForTests(): void { + tails.clear() +} + diff --git a/src/stackflow/components/TabBar.tsx b/src/stackflow/components/TabBar.tsx index 22df475b8..245c6b887 100644 --- a/src/stackflow/components/TabBar.tsx +++ b/src/stackflow/components/TabBar.tsx @@ -286,10 +286,10 @@ export function TabBar({ activeTab, onTabChange, className }: TabBarProps) { key={tab.id} hasRunningApps={hasRunningApps} onSwipeUp={handleEcosystemSwipeUp} + onTap={handleEcosystemClick} className={buttonClassName} >