From c3d86a4ea7ff38093c23f8fea8539ee2e8315d82 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Mon, 22 Jun 2026 04:54:32 -0700 Subject: [PATCH] fix(chat): decouple provider webviews from thread routes (#3896) --- app/src/App.tsx | 39 +++- app/src/AppRoutes.tsx | 2 +- app/src/AppRoutesIOS.tsx | 2 +- app/src/__tests__/App.webviewOverlay.test.tsx | 153 +++++++++++++ app/src/components/OpenhumanLinkModal.tsx | 7 +- .../OpenhumanLinkModal.accounts.test.tsx | 4 +- .../IntelligenceAgentWorkTab.test.tsx | 18 +- .../intelligence/IntelligenceAgentWorkTab.tsx | 5 +- .../intelligence/IntelligenceAgentsTab.tsx | 3 +- .../intelligence/IntelligenceTasksTab.tsx | 8 +- .../__tests__/IntelligenceAgentsTab.test.tsx | 2 +- .../__tests__/IntelligenceTasksTab.test.tsx | 4 +- .../layout/shell/SidebarAppRail.test.tsx | 76 +++++++ .../layout/shell/SidebarAppRail.tsx | 14 +- .../layout/shell/SidebarNav.test.tsx | 33 ++- .../components/layout/shell/SidebarNav.tsx | 6 +- .../layout/shell/useHomeNav.test.tsx | 4 +- app/src/components/layout/shell/useHomeNav.ts | 3 + app/src/pages/Accounts.tsx | 71 ++---- app/src/pages/Conversations.tsx | 35 ++- .../Accounts.webviewSelection.test.tsx | 97 ++++++++ .../__tests__/Conversations.render.test.tsx | 213 +++++++++++++++++- ...webviewAccountService.loadListener.test.ts | 17 ++ app/src/services/webviewAccountService.ts | 6 + app/src/store/accountsSlice.ts | 5 +- app/src/store/index.ts | 2 +- app/src/test/test-utils.tsx | 2 + app/src/utils/chatRoutes.ts | 3 + 28 files changed, 729 insertions(+), 105 deletions(-) create mode 100644 app/src/__tests__/App.webviewOverlay.test.tsx create mode 100644 app/src/components/layout/shell/SidebarAppRail.test.tsx create mode 100644 app/src/pages/__tests__/Accounts.webviewSelection.test.tsx create mode 100644 app/src/utils/chatRoutes.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index 6bf335a54..105ed2cf8 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -10,6 +10,7 @@ import { import { PersistGate } from 'redux-persist/integration/react'; import AppRoutes from './AppRoutes'; +import WebviewHost from './components/accounts/WebviewHost'; import AppBackground from './components/AppBackground'; import AppUpdatePrompt from './components/AppUpdatePrompt'; import BootCheckGate from './components/BootCheckGate/BootCheckGate'; @@ -57,7 +58,8 @@ import { stopWebviewAccountService, } from './services/webviewAccountService'; import { persistor, store } from './store'; -import { useAppSelector } from './store/hooks'; +import { setActiveAccount } from './store/accountsSlice'; +import { useAppDispatch, useAppSelector } from './store/hooks'; import { AGENT_ACCOUNT_ID } from './utils/accountsFullscreen'; import { DEV_FORCE_ONBOARDING } from './utils/config'; @@ -161,9 +163,10 @@ function AppShell() { } /** Desktop inner shell — lives inside the Router so it can use useLocation. */ -function AppShellDesktop() { +export function AppShellDesktop() { const location = useLocation(); const navigate = useNavigate(); + const dispatch = useAppDispatch(); const { snapshot, isBootstrapping } = useCoreState(); const onOnboardingRoute = location.pathname.startsWith('/onboarding'); const onboardingPending = @@ -200,15 +203,24 @@ function AppShellDesktop() { }, [location.pathname]); // Hide the active connected-app webview when we navigate away from the chat - // surface. The native CEF webview composites above the HTML, so without this - // it lingers on top of the newly-routed page until the user returns. + // surface. Provider CEF selection is intentionally route-independent; any + // real route change clears that high-level selection so the native view + // cannot linger over the newly-routed page. const activeAccountId = useAppSelector(state => state.accounts.activeAccountId); + const accountsById = useAppSelector(state => state.accounts.accounts); + const accountsOverlayOpen = useAppSelector(state => state.accounts.overlayOpen); + const previousPathRef = useRef(location.pathname); useEffect(() => { - const onChat = location.pathname === '/chat' || location.pathname.startsWith('/chat/'); - if (!onChat && activeAccountId && activeAccountId !== AGENT_ACCOUNT_ID) { + if ( + location.pathname !== previousPathRef.current && + activeAccountId && + activeAccountId !== AGENT_ACCOUNT_ID + ) { void hideWebviewAccount(activeAccountId); + dispatch(setActiveAccount(AGENT_ACCOUNT_ID)); } - }, [location.pathname, activeAccountId]); + previousPathRef.current = location.pathname; + }, [dispatch, location.pathname, activeAccountId]); // Sync the notch indicator to the persisted always-on listening state once // the core is ready (once per boot). Extracted to a hook so it's testable. @@ -232,10 +244,23 @@ function AppShellDesktop() { ); const chromeless = !token || onOnboardingRoute || onHiddenChromePath; + const activeProviderAccount = + activeAccountId && activeAccountId !== AGENT_ACCOUNT_ID + ? (accountsById[activeAccountId] ?? null) + : null; + const content = (
+ {activeProviderAccount && !accountsOverlayOpen && ( +
+ +
+ )}
); diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index 99c580781..2f0ecdd64 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -128,7 +128,7 @@ const AppRoutes = () => { {/* Unified chat = agent + connected web apps. Replaces the old /conversations and /accounts routes. */} diff --git a/app/src/AppRoutesIOS.tsx b/app/src/AppRoutesIOS.tsx index 7bd074d81..67d5553bc 100644 --- a/app/src/AppRoutesIOS.tsx +++ b/app/src/AppRoutesIOS.tsx @@ -66,7 +66,7 @@ const AppRoutesIOS: FC = () => { } /> diff --git a/app/src/__tests__/App.webviewOverlay.test.tsx b/app/src/__tests__/App.webviewOverlay.test.tsx new file mode 100644 index 000000000..cef247dd2 --- /dev/null +++ b/app/src/__tests__/App.webviewOverlay.test.tsx @@ -0,0 +1,153 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { useEffect } from 'react'; +import { MemoryRouter, useNavigate } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AppShellDesktop } from '../App'; + +const { hideWebviewAccountMock, mockDispatch } = vi.hoisted(() => ({ + hideWebviewAccountMock: vi.fn().mockResolvedValue(undefined), + mockDispatch: vi.fn(), +})); + +const baseState = { + accounts: { + accounts: { + 'acct-whatsapp': { + id: 'acct-whatsapp', + provider: 'whatsapp', + label: 'WhatsApp', + createdAt: '2026-01-01T00:00:00.000Z', + status: 'open', + }, + }, + order: ['acct-whatsapp'], + activeAccountId: 'acct-whatsapp', + lastActiveAccountId: 'acct-whatsapp', + messages: {}, + unread: {}, + logs: {}, + overlayOpen: false, + }, +}; + +let mockState = baseState; + +vi.mock('../services/webviewAccountService', () => ({ + hideWebviewAccount: hideWebviewAccountMock, + startWebviewAccountService: vi.fn(), + stopWebviewAccountService: vi.fn(), +})); +vi.mock('../lib/webviewNotifications', () => ({ + startWebviewNotificationsService: vi.fn(), + stopWebviewNotificationsService: vi.fn(), +})); +vi.mock('../lib/nativeNotifications', () => ({ + startNativeNotificationsService: vi.fn(), + stopNativeNotificationsService: vi.fn(), +})); +vi.mock('../services/internetStatusListener', () => ({ + startInternetStatusListener: vi.fn(), + stopInternetStatusListener: vi.fn(), +})); +vi.mock('../services/coreHealthMonitor', () => ({ + startCoreHealthMonitor: vi.fn(), + stopCoreHealthMonitor: vi.fn(), +})); +vi.mock('../services/analytics', () => ({ trackPageView: vi.fn() })); +vi.mock('../hooks/useNotchBootSync', () => ({ useNotchBootSync: vi.fn() })); +vi.mock('../providers/CoreStateProvider', () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, + useCoreState: () => ({ + snapshot: { sessionToken: 'token', onboardingCompleted: true }, + isBootstrapping: false, + }), +})); +vi.mock('../store/hooks', () => ({ + useAppDispatch: () => mockDispatch, + useAppSelector: (selector: (state: typeof baseState) => unknown) => selector(mockState), +})); +vi.mock('../components/accounts/WebviewHost', () => ({ + default: ({ accountId }: { accountId: string }) => ( +
{accountId}
+ ), +})); +vi.mock('../AppRoutes', () => ({ default: () =>
})); +vi.mock('../components/AppBackground', () => ({ default: () => null })); +vi.mock('../components/layout/shell/AppSidebar', () => ({ default: () =>