fix(chat): decouple provider webviews from thread routes (#3896)

This commit is contained in:
Steven Enamakel
2026-06-22 17:24:32 +05:30
committed by GitHub
parent d4db739b22
commit c3d86a4ea7
28 changed files with 729 additions and 105 deletions
+32 -7
View File
@@ -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 = (
<div ref={scrollRef} className="relative h-full overflow-y-auto">
<GlobalUpsellBanner />
<AppRoutes />
{activeProviderAccount && !accountsOverlayOpen && (
<div className="absolute inset-0 z-30">
<WebviewHost
accountId={activeProviderAccount.id}
provider={activeProviderAccount.provider}
/>
</div>
)}
</div>
);
+1 -1
View File
@@ -128,7 +128,7 @@ const AppRoutes = () => {
{/* Unified chat = agent + connected web apps. Replaces the old
/conversations and /accounts routes. */}
<Route
path="/chat"
path="/chat/:threadId?"
element={
<ProtectedRoute requireAuth={true}>
<Accounts />
+1 -1
View File
@@ -66,7 +66,7 @@ const AppRoutesIOS: FC = () => {
}
/>
<Route
path="/chat"
path="/chat/:threadId?"
element={
<RequirePairing>
<Accounts />
@@ -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 }) => (
<div data-testid="webview-host">{accountId}</div>
),
}));
vi.mock('../AppRoutes', () => ({ default: () => <main data-testid="routes" /> }));
vi.mock('../components/AppBackground', () => ({ default: () => null }));
vi.mock('../components/layout/shell/AppSidebar', () => ({ default: () => <aside /> }));
vi.mock('../components/layout/shell/RootShellLayout', () => ({
default: ({ sidebar, children }: { sidebar: React.ReactNode; children: React.ReactNode }) => (
<div>
{sidebar}
{children}
</div>
),
}));
vi.mock('../components/layout/shell/SidebarSlot', () => ({
SidebarSlotProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('../components/OpenhumanLinkModal', () => ({ default: () => null }));
vi.mock('../components/upsell/GlobalUpsellBanner', () => ({ default: () => null }));
vi.mock('../features/meet/MascotFrameProducer', () => ({ MascotFrameProducer: () => null }));
vi.mock('../components/walkthrough/AppWalkthrough', () => ({ default: () => null }));
describe('AppShellDesktop provider webview visibility', () => {
beforeEach(() => {
vi.clearAllMocks();
HTMLElement.prototype.scrollTo = vi.fn();
mockState = baseState;
});
it('mounts the active provider webview when rail overlays are closed', () => {
renderShell();
expect(screen.getByTestId('webview-host')).toHaveTextContent('acct-whatsapp');
});
it('does not mount the provider webview while a rail overlay is open', () => {
mockState = { ...baseState, accounts: { ...baseState.accounts, overlayOpen: true } };
renderShell();
expect(screen.queryByTestId('webview-host')).not.toBeInTheDocument();
});
it('does not mount a provider webview when the active account is missing', () => {
mockState = {
...baseState,
accounts: { ...baseState.accounts, activeAccountId: 'missing-account' },
};
renderShell();
expect(screen.queryByTestId('webview-host')).not.toBeInTheDocument();
});
it('hides the active provider and restores the agent selection on route changes', async () => {
renderShell('/chat/thread-1', '/settings');
await waitFor(() => expect(hideWebviewAccountMock).toHaveBeenCalledWith('acct-whatsapp'));
expect(mockDispatch).toHaveBeenCalledWith({
type: 'accounts/setActiveAccount',
payload: '__agent__',
});
});
});
function renderShell(initialPath = '/chat/thread-1', nextPath?: string) {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<RouteChangeHarness nextPath={nextPath} />
</MemoryRouter>
);
}
function RouteChangeHarness({ nextPath }: { nextPath?: string }) {
const navigate = useNavigate();
useEffect(() => {
if (nextPath) {
navigate(nextPath);
}
}, [navigate, nextPath]);
return <AppShellDesktop />;
}
+2 -5
View File
@@ -11,7 +11,6 @@
* Mounted once at AppShell root.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useChannelDefinitions } from '../hooks/useChannelDefinitions';
import { useT } from '../lib/i18n/I18nContext';
@@ -456,7 +455,6 @@ export function statusDisplay(status: AccountStatus): { labelKey: string; dotCla
const AccountsSetupBody = ({ close }: { close: () => void }) => {
const { t } = useT();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const accountsById = useAppSelector(s => s.accounts.accounts);
const order = useAppSelector(s => s.accounts.order);
@@ -508,12 +506,11 @@ const AccountsSetupBody = ({ close }: { close: () => void }) => {
const handleDone = () => {
close();
// Navigate to /chat and activate the first newly-added account so its
// WebviewHost mounts and the auth flow starts immediately.
// Activate the first newly-added account so the shell-level WebviewHost
// opens the auth flow immediately without mutating the current route.
const firstNew = [...newlyAdded.keys()][0];
if (firstNew) {
dispatch(setActiveAccount(firstNew));
navigate('/chat');
}
};
@@ -98,7 +98,7 @@ describe('OpenhumanLinkModal accounts setup', () => {
expect(Object.values(store.getState().accounts.accounts)).toHaveLength(0);
});
it('Done button navigates to /chat and sets first new account as active', () => {
it('Done button sets first new account as active without navigating', () => {
const { store } = renderModal();
openAccountsModal();
@@ -113,7 +113,7 @@ describe('OpenhumanLinkModal accounts setup', () => {
fireEvent.click(screen.getByRole('button', { name: /Continue with Telegram Web sign-in/ }));
expect(store.getState().accounts.activeAccountId).toBe(accountIds[0]);
expect(mockNavigate).toHaveBeenCalledWith('/chat');
expect(mockNavigate).not.toHaveBeenCalled();
});
it('Skip button closes modal without navigating', () => {
@@ -8,6 +8,8 @@ import {
} from '../../services/api/agentWorkApi';
import IntelligenceAgentWorkTab from './IntelligenceAgentWorkTab';
const hoisted = vi.hoisted(() => ({ dispatch: vi.fn(), navigate: vi.fn() }));
vi.mock('../../services/api/agentWorkApi', () => ({
agentWorkApi: { list: vi.fn(), control: vi.fn() },
}));
@@ -16,8 +18,8 @@ vi.mock('../../services/api/agentWorkApi', () => ({
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
// Navigation + store: the tab only dispatches + navigates on click; stub them.
vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() }));
vi.mock('../../store/hooks', () => ({ useAppDispatch: () => vi.fn() }));
vi.mock('react-router-dom', () => ({ useNavigate: () => hoisted.navigate }));
vi.mock('../../store/hooks', () => ({ useAppDispatch: () => hoisted.dispatch }));
vi.mock('../../store/threadSlice', () => ({
loadThreadMessages: vi.fn(),
loadThreads: vi.fn(),
@@ -104,6 +106,8 @@ describe('IntelligenceAgentWorkTab', () => {
// call history, not queued *Once values / persistent implementations).
mockList.mockReset();
mockControl.mockReset();
hoisted.dispatch.mockReset();
hoisted.navigate.mockReset();
});
it('fetches agent work on mount', async () => {
@@ -148,6 +152,16 @@ describe('IntelligenceAgentWorkTab', () => {
expect(screen.getByText('intelligence.agentWork.openWorker')).toBeInTheDocument();
});
it('opens worker threads on the routed chat URL', async () => {
mockList.mockResolvedValue(workingResponse());
render(<IntelligenceAgentWorkTab />);
await waitFor(() => expect(screen.getByText('Researcher')).toBeInTheDocument());
fireEvent.click(screen.getByText('intelligence.agentWork.openWorker'));
expect(hoisted.navigate).toHaveBeenCalledWith('/chat/thread-w');
});
it('shows Stop (not Retry/Continue) for a live working run', async () => {
mockList.mockResolvedValue(singleRowResponse('working', 'running'));
render(<IntelligenceAgentWorkTab />);
@@ -11,7 +11,7 @@
* {data, loading, error} state, mirroring {@link IntelligenceTasksTab}'s
* mount pattern (mountedRef + a 0ms `setTimeout` so the first paint shows the
* loading state before the RPC resolves). Each row offers jumps to the parent
* / worker thread via the same navigate('/chat', { openThreadId }) path the
* / worker thread via the same /chat/:threadId path the
* Tasks tab uses for "View session".
*/
import debug from 'debug';
@@ -28,6 +28,7 @@ import {
} from '../../services/api/agentWorkApi';
import { useAppDispatch } from '../../store/hooks';
import { loadThreadMessages, loadThreads, setSelectedThread } from '../../store/threadSlice';
import { chatThreadPath } from '../../utils/chatRoutes';
const log = debug('intelligence:agent-work');
@@ -158,7 +159,7 @@ export default function IntelligenceAgentWorkTab() {
dispatch(setSelectedThread(threadId));
void dispatch(loadThreads());
void dispatch(loadThreadMessages(threadId));
navigate('/chat', { state: { openThreadId: threadId } });
navigate(chatThreadPath(threadId));
},
[dispatch, navigate]
);
@@ -16,6 +16,7 @@ import {
setSelectedThread,
} from '../../store/threadSlice';
import type { ThreadMessage } from '../../types/thread';
import { chatThreadPath } from '../../utils/chatRoutes';
import AgentsLibraryPanel from './AgentsLibraryPanel';
const log = debug('intelligence:agents-tab');
@@ -82,7 +83,7 @@ export default function IntelligenceAgentsTab() {
dispatch(setActiveThread(thread.id));
void dispatch(loadThreads());
void dispatch(loadThreadMessages(thread.id));
navigate('/chat');
navigate(chatThreadPath(thread.id));
await chatSend({
threadId: thread.id,
@@ -47,6 +47,7 @@ import {
} from '../../store/threadSlice';
import type { ThreadMessage } from '../../types/thread';
import type { TaskBoard, TaskBoardCard, TaskBoardCardStatus } from '../../types/turnState';
import { chatThreadPath } from '../../utils/chatRoutes';
import { UserTaskComposer } from './UserTaskComposer';
const log = debug('intelligence:tasks');
@@ -391,7 +392,7 @@ export default function IntelligenceTasksTab() {
dispatch(setActiveThread(thread.id));
void dispatch(loadThreads());
void dispatch(loadThreadMessages(thread.id));
navigate('/chat');
navigate(chatThreadPath(thread.id));
await chatSend({
threadId: thread.id,
@@ -588,10 +589,7 @@ export default function IntelligenceTasksTab() {
dispatch(setSelectedThread(tid));
void dispatch(loadThreads());
void dispatch(loadThreadMessages(tid));
// Pass the thread as an explicit open-intent so Conversations'
// mount-resume honors it (its default resume only considers
// General-tab threads and would otherwise drop this task session).
navigate('/chat', { state: { openThreadId: tid } });
navigate(chatThreadPath(tid));
}}
workingCardId={workingCardId}
mutatingCardId={mutatingCardId}
@@ -174,6 +174,6 @@ describe('IntelligenceAgentsTab', () => {
})
)
);
expect(hoisted.navigate).toHaveBeenCalledWith('/chat');
expect(hoisted.navigate).toHaveBeenCalledWith('/chat/thread-agent-task');
});
});
@@ -432,9 +432,7 @@ describe('IntelligenceTasksTab', () => {
await waitFor(() => expect(screen.getByText('Worked card')).toBeInTheDocument());
fireEvent.click(screen.getByText('stub-view-session'));
expect(hoisted.navigate).toHaveBeenCalledWith('/chat', {
state: { openThreadId: 'task-session-99' },
});
expect(hoisted.navigate).toHaveBeenCalledWith('/chat/task-session-99');
});
test('renders persisted agent boards from the turn-state list', async () => {
@@ -0,0 +1,76 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import SidebarAppRail from './SidebarAppRail';
const mockNavigate = vi.fn();
const mockDispatch = vi.fn();
const mockState = {
accounts: {
accounts: {
'acct-whatsapp': {
id: 'acct-whatsapp',
provider: 'whatsapp',
label: 'WhatsApp',
createdAt: '2026-01-01T00:00:00.000Z',
status: 'open',
},
},
order: ['acct-whatsapp'],
activeAccountId: null,
unread: {},
},
};
vi.mock('react-router-dom', async importOriginal => {
const actual = await importOriginal<typeof import('react-router-dom')>();
return { ...actual, useNavigate: () => mockNavigate };
});
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
vi.mock('../../../services/analytics', () => ({ trackEvent: vi.fn() }));
vi.mock('../../../services/webviewAccountService', () => ({ purgeWebviewAccount: vi.fn() }));
vi.mock('../../../store/hooks', () => ({
useAppDispatch: () => mockDispatch,
useAppSelector: (sel: (state: typeof mockState) => unknown) => sel(mockState),
}));
describe('SidebarAppRail', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('selects a provider webview without mutating the current route', () => {
renderRail('/chat/thread-1');
fireEvent.click(screen.getByRole('button', { name: 'WhatsApp' }));
expect(mockNavigate).not.toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledWith({
type: 'accounts/setActiveAccount',
payload: 'acct-whatsapp',
});
});
it('does not navigate again when selecting the agent from a thread route', () => {
renderRail('/chat/thread-1');
fireEvent.click(screen.getByRole('button', { name: 'accounts.agent' }));
expect(mockNavigate).not.toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledWith({
type: 'accounts/setActiveAccount',
payload: '__agent__',
});
});
});
function renderRail(route: string) {
return render(
<MemoryRouter initialEntries={[route]}>
<SidebarAppRail />
</MemoryRouter>
);
}
@@ -1,6 +1,6 @@
import debugFactory from 'debug';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { trackEvent } from '../../../services/analytics';
@@ -92,6 +92,7 @@ export default function SidebarAppRail() {
const { t } = useT();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const location = useLocation();
const accountsById = useAppSelector(state => state.accounts.accounts);
const order = useAppSelector(state => state.accounts.order);
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
@@ -120,11 +121,12 @@ export default function SidebarAppRail() {
dispatch(setAccountsOverlayOpen(overlayOpen));
}, [overlayOpen, dispatch]);
// Bring the chat surface forward whenever the user picks something on the
// rail from another route — that's where the agent chat / provider webview
// actually render.
// Bring the chat surface forward when the user explicitly picks the agent.
// Provider CEF selection is a high-level rail overlay and intentionally does
// not mutate the current route.
const goToChat = () => {
if (!window.location.hash.replace(/^#/, '').startsWith('/chat')) {
const onChat = location.pathname === '/chat' || location.pathname.startsWith('/chat/');
if (!onChat) {
navigate('/chat');
}
};
@@ -145,7 +147,6 @@ export default function SidebarAppRail() {
// Issue #1233 — record this real-account selection in the persisted MRU
// pointer so the next session can prewarm it.
dispatch(setLastActiveAccount(id));
goToChat();
};
const selectAgent = () => {
@@ -170,7 +171,6 @@ export default function SidebarAppRail() {
}
dispatch(setActiveAccount(id));
dispatch(setLastActiveAccount(id));
goToChat();
};
const openContextMenu = (accountId: string, e: React.MouseEvent) => {
@@ -1,7 +1,8 @@
import { screen } from '@testing-library/react';
import { fireEvent, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../test/test-utils';
import { AGENT_ACCOUNT_ID } from '../../../utils/accountsFullscreen';
import SidebarNav from './SidebarNav';
// Analytics is fire-and-forget; stub it so the nav renders without a transport.
@@ -33,4 +34,34 @@ describe('SidebarNav active matching', () => {
expect(tabButton('Tiny.Place')).not.toHaveAttribute('aria-current');
expect(tabButton('Chat')).toHaveAttribute('aria-current', 'page');
});
it('clears an active provider selection when clicking the already-active nav item', () => {
const { store } = renderWithProviders(<SidebarNav />, {
initialEntries: ['/connections'],
preloadedState: {
accounts: {
accounts: {
'acct-slack': {
id: 'acct-slack',
provider: 'slack',
label: 'Slack',
createdAt: '2026-01-01T00:00:00.000Z',
status: 'open',
},
},
order: ['acct-slack'],
activeAccountId: 'acct-slack',
lastActiveAccountId: 'acct-slack',
messages: {},
unread: {},
logs: {},
overlayOpen: false,
},
},
});
fireEvent.click(tabButton('Connections'));
expect(store.getState().accounts.activeAccountId).toBe(AGENT_ACCOUNT_ID);
});
});
@@ -4,9 +4,11 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { NAV_TABS, type NavTab } from '../../../config/navConfig';
import { useT } from '../../../lib/i18n/I18nContext';
import { trackEvent } from '../../../services/analytics';
import { setActiveAccount } from '../../../store/accountsSlice';
import { selectCompanionSessionActive } from '../../../store/companionSlice';
import { useAppSelector } from '../../../store/hooks';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { selectUnreadCount } from '../../../store/notificationSlice';
import { AGENT_ACCOUNT_ID } from '../../../utils/accountsFullscreen';
import { NavIcon } from './navIcons';
/**
@@ -35,6 +37,7 @@ function matchActive(path: string, pathname: string): boolean {
*/
export default function SidebarNav() {
const { t } = useT();
const dispatch = useAppDispatch();
const location = useLocation();
const navigate = useNavigate();
const unreadCount = useAppSelector(state => selectUnreadCount(state.notifications.items));
@@ -44,6 +47,7 @@ export default function SidebarNav() {
const activeTab = tabs.find(tab => matchActive(tab.path, location.pathname));
const handleClick = (tab: NavTab, active: boolean) => {
dispatch(setActiveAccount(AGENT_ACCOUNT_ID));
if (!active) {
trackEvent('tab_bar_change', {
from_tab: activeTab?.id ?? 'unknown',
@@ -95,7 +95,7 @@ describe('useHomeNav', () => {
const { result } = renderHook(() => useHomeNav(), { wrapper });
result.current();
expect(mockNavigate).not.toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith('/chat/empty-1');
expect(mockDispatch).toHaveBeenCalledWith({
type: 'thread/setSelectedThread',
payload: 'empty-1',
@@ -125,7 +125,7 @@ describe('useHomeNav', () => {
type: 'thread/loadThreadMessages',
payload: 'fresh-thread',
});
expect(mockNavigate).not.toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith('/chat/fresh-thread');
});
it('swallows a failed thread creation without throwing', async () => {
@@ -5,6 +5,7 @@ import { setActiveAccount } from '../../../store/accountsSlice';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { createNewThread, loadThreadMessages, setSelectedThread } from '../../../store/threadSlice';
import { AGENT_ACCOUNT_ID } from '../../../utils/accountsFullscreen';
import { chatThreadPath } from '../../../utils/chatRoutes';
/**
* The shell's "Home" action — shared by the sidebar header (expanded) and the
@@ -35,6 +36,7 @@ export function useHomeNav(): () => void {
if (empty) {
dispatch(setSelectedThread(empty.id));
void dispatch(loadThreadMessages(empty.id));
navigate(chatThreadPath(empty.id));
return;
}
void dispatch(createNewThread())
@@ -42,6 +44,7 @@ export function useHomeNav(): () => void {
.then(thr => {
dispatch(setSelectedThread(thr.id));
void dispatch(loadThreadMessages(thr.id));
navigate(chatThreadPath(thr.id));
})
.catch(() => {});
}, [navigate, location.pathname, dispatch, threads]);
+20 -51
View File
@@ -1,6 +1,7 @@
import debugFactory from 'debug';
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import WebviewHost from '../components/accounts/WebviewHost';
import {
CustomGifMascot,
getMascotPalette,
@@ -10,12 +11,9 @@ import {
import { useHumanMascot } from '../features/human/useHumanMascot';
import { usePrewarmMostRecentAccount } from '../hooks/usePrewarmMostRecentAccount';
import { useT } from '../lib/i18n/I18nContext';
import {
hideWebviewAccount,
showWebviewAccount,
startWebviewAccountService,
} from '../services/webviewAccountService';
import { useAppSelector } from '../store/hooks';
import { startWebviewAccountService } from '../services/webviewAccountService';
import { setActiveAccount } from '../store/accountsSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import {
selectCustomMascotGifUrl,
selectCustomPrimaryColor,
@@ -28,6 +26,7 @@ import Conversations, { AgentChatPanel } from './Conversations';
// Persistence key for face-toggle state across sessions.
const FACE_MODE_KEY = 'chat.faceMode';
const debug = debugFactory('accounts');
/**
* Mascot + TTS panel rendered in face mode (right column of the Assistant
@@ -107,13 +106,11 @@ const FaceModePanel = () => {
};
const Accounts = () => {
const { t } = useT();
const dispatch = useAppDispatch();
const { threadId } = useParams<{ threadId?: string }>();
const accountsById = useAppSelector(state => state.accounts.accounts);
const order = useAppSelector(state => state.accounts.order);
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
// Overlay state is owned by the persistent app rail (now in the sidebar); we
// only read it here to hide/restore the active provider webview.
const overlayOpen = useAppSelector(state => state.accounts.overlayOpen);
const [faceMode] = useState<boolean>(() => {
try {
@@ -131,6 +128,12 @@ const Accounts = () => {
startWebviewAccountService();
}, []);
useEffect(() => {
if (!threadId) return;
debug('[chat][route] selecting agent for thread route thread=%s', threadId);
dispatch(setActiveAccount(AGENT_ID));
}, [dispatch, threadId]);
// Issue #1233 — prewarm the MRU account once on mount so its CEF profile
// and provider page are warm before the user actually clicks the rail.
// Skipped for power users with many accounts to bound the spawn cost.
@@ -142,24 +145,8 @@ const Accounts = () => {
usePrewarmMostRecentAccount({ accounts, accountsById, activeAccountId });
const selectedId = activeAccountId ?? AGENT_ID;
const active = selectedId === AGENT_ID ? null : (accountsById[selectedId] ?? null);
const isAgentSelected = selectedId === AGENT_ID;
// The child Tauri webview is a native view composited above the HTML
// canvas, so DOM z-index can't put React overlays on top of it. Hide
// the active webview while a rail overlay (add-account modal or the
// right-click context menu, both owned by the persistent sidebar rail) is
// open and restore it on close. No-op when the agent pane is selected.
const activeId = active?.id ?? null;
useEffect(() => {
if (!activeId) return;
if (overlayOpen) {
void hideWebviewAccount(activeId);
} else {
void showWebviewAccount(activeId);
}
}, [overlayOpen, activeId]);
return (
<div
// `h-full` makes this page fill the shell's content box edge-to-edge.
@@ -169,10 +156,9 @@ const Accounts = () => {
{/* "Talk to Tiny" face-mode toggle — hidden (kept for potential re-enable). */}
{/* Main pane. In face mode (agent selected) it's a horizontal split with
the mascot panel. Otherwise the agent chat is ALWAYS mounted — so the
thread sidebar it projects stays consistent regardless of which app is
selected — and a selected app's webview fills the pane edge-to-edge on
top of it. */}
the mascot panel. Connected-app CEF views are hosted above this page
by the desktop shell, so the routed chat panel must only mount while
the agent is active; its thread effects own `/chat/:threadId`. */}
{isAgentSelected && faceMode ? (
<main className="flex min-w-0 flex-1 flex-row gap-3">
<div className="flex min-h-0 w-[360px] flex-none flex-col">
@@ -184,26 +170,9 @@ const Accounts = () => {
</main>
) : (
<main className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Agent chat — kept mounted even while a webview app is shown so its
thread sidebar projection persists. `min-h-0` lets the message list
own the scroll instead of pushing the composer off-screen. */}
<div
className={`min-h-0 flex-1 overflow-hidden ${isAgentSelected ? '' : 'invisible'}`}
aria-hidden={!isAgentSelected}>
<AgentChatPanel />
</div>
{/* Selected connected app — fills the main content fully (no padding
or margins) on top of the hidden agent chat. */}
{!isAgentSelected && active && (
<div className="absolute inset-0">
<WebviewHost accountId={active.id} provider={active.provider} />
</div>
)}
{!isAgentSelected && !active && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-stone-400 dark:text-neutral-500">
{t('accounts.noAccounts')}
{isAgentSelected && (
<div className="min-h-0 flex-1 overflow-hidden">
<AgentChatPanel />
</div>
)}
</main>
+32 -3
View File
@@ -1,7 +1,7 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import debugFactory from 'debug';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { type ChatSendError, chatSendError } from '../chat/chatSendError';
import { checkPromptInjection, promptGuardMessage } from '../chat/promptInjectionGuard';
@@ -67,6 +67,7 @@ import type { ConfirmationModal as ConfirmationModalType } from '../types/intell
import type { ThreadMessage } from '../types/thread';
import type { TaskBoardCard, TaskBoardCardStatus } from '../types/turnState';
import { splitAgentMessageIntoBubbles } from '../utils/agentMessageBubbles';
import { chatThreadPath } from '../utils/chatRoutes';
import { CHAT_ATTACHMENTS_ENABLED } from '../utils/config';
import { BILLING_DASHBOARD_URL } from '../utils/links';
import { openUrl } from '../utils/openUrl';
@@ -208,6 +209,8 @@ const Conversations = ({
const dispatch = useAppDispatch();
const navigate = useNavigate();
const location = useLocation();
const { threadId: routeThreadId } = useParams<{ threadId?: string }>();
const shouldSyncChatRoute = variant === 'page' && location.pathname.startsWith('/chat');
const { threads, selectedThreadId, messages, isLoadingMessages, messagesError } = useAppSelector(
state => state.thread
);
@@ -428,6 +431,12 @@ const Conversations = ({
const thread = await dispatch(createNewThread()).unwrap();
dispatch(setSelectedThread(thread.id));
void dispatch(loadThreadMessages(thread.id));
if (shouldSyncChatRoute) {
debug('[chat][route] created thread thread=%s navigate=true', thread.id);
navigate(chatThreadPath(thread.id));
} else {
debug('[chat][route] created thread thread=%s navigate=false', thread.id);
}
};
const handleUseOpenRouterFree = async () => {
@@ -502,7 +511,8 @@ const Conversations = ({
// Tasks board) wins over passive resume — and bypasses the General-tab
// visibility filter so a task-labelled session thread can actually be
// opened (the resume default below only considers General threads).
const openThreadId = (location.state as { openThreadId?: string } | null)?.openThreadId;
const openThreadId =
routeThreadId ?? (location.state as { openThreadId?: string } | null)?.openThreadId;
const openThread = openThreadId ? data.threads.find(t => t.id === openThreadId) : undefined;
if (openThread) {
// An explicit open intent (e.g. View work from the Tasks board) opens
@@ -510,6 +520,12 @@ const Conversations = ({
// filtered to General.
dispatch(setSelectedThread(openThread.id));
void dispatch(loadThreadMessages(openThread.id));
debug('[chat][route] opened requested thread thread=%s', openThread.id);
return;
}
if (openThreadId) {
debug('[chat][route] requested thread not found thread=%s; falling back', openThreadId);
navigate('/chat', { replace: true });
return;
}
// Default landing is a fresh "new window" (the merged Home surface) —
@@ -534,7 +550,7 @@ const Conversations = ({
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch]);
}, [dispatch, routeThreadId]);
useEffect(() => {
if (selectedThreadId) {
@@ -1584,6 +1600,9 @@ const Conversations = ({
onClick={() => {
dispatch(setSelectedThread(thread.id));
void dispatch(loadThreadMessages(thread.id));
if (shouldSyncChatRoute) {
navigate(chatThreadPath(thread.id));
}
}}
onKeyDown={e => {
if (e.target !== e.currentTarget) return;
@@ -1591,6 +1610,9 @@ const Conversations = ({
e.preventDefault();
dispatch(setSelectedThread(thread.id));
void dispatch(loadThreadMessages(thread.id));
if (shouldSyncChatRoute) {
navigate(chatThreadPath(thread.id));
}
}
}}
className={`w-full text-left px-3 py-1.5 border-b border-stone-100/60 dark:border-neutral-800/60 transition-colors group cursor-pointer ${
@@ -1677,6 +1699,9 @@ const Conversations = ({
cancelText: t('common.cancel'),
destructive: true,
onConfirm: () => {
if (shouldSyncChatRoute && routeThreadId === thread.id) {
navigate('/chat', { replace: true });
}
void dispatch(deleteThread(thread.id));
},
onCancel: () => {},
@@ -1802,6 +1827,9 @@ const Conversations = ({
// event, so forcing it active would wedge the composer.
dispatch(setSelectedThread(card.sessionThreadId));
void dispatch(loadThreadMessages(card.sessionThreadId));
if (shouldSyncChatRoute) {
navigate(chatThreadPath(card.sessionThreadId));
}
}}
/>
)}
@@ -2545,6 +2573,7 @@ const Conversations = ({
onClick={() => {
dispatch(setSelectedThread(selectedThreadParent.id));
void dispatch(loadThreadMessages(selectedThreadParent.id));
navigate(chatThreadPath(selectedThreadParent.id));
}}
className="mt-2 flex items-center gap-1 rounded px-1 text-[11px] font-medium text-primary-600 hover:text-primary-700 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-300"
data-testid="worker-thread-back-to-parent">
@@ -0,0 +1,97 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import Accounts from '../Accounts';
const mockDispatch = vi.fn();
const agentState = {
accounts: {
accounts: {
'acct-whatsapp': {
id: 'acct-whatsapp',
provider: 'whatsapp',
label: 'WhatsApp',
createdAt: '2026-01-01T00:00:00.000Z',
status: 'open',
},
},
order: ['acct-whatsapp'],
activeAccountId: '__agent__',
},
};
let mockState = agentState;
vi.mock('../../hooks/usePrewarmMostRecentAccount', () => ({
usePrewarmMostRecentAccount: vi.fn(),
}));
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
vi.mock('../../services/webviewAccountService', () => ({ startWebviewAccountService: vi.fn() }));
vi.mock('../../store/hooks', () => ({
useAppDispatch: () => mockDispatch,
useAppSelector: (selector: (state: typeof agentState) => unknown) => selector(mockState),
}));
vi.mock('../../features/human/Mascot', () => ({
CustomGifMascot: () => null,
RiveMascot: () => null,
getMascotPalette: () => ({ bodyFill: '#000000', neckShadowColor: '#111111' }),
hexToArgbInt: () => 0,
}));
vi.mock('../../features/human/useHumanMascot', () => ({
useHumanMascot: () => ({ face: null, visemeCode: null }),
}));
vi.mock('../../store/mascotSlice', () => ({
selectCustomMascotGifUrl: () => null,
selectCustomPrimaryColor: () => '#000000',
selectCustomSecondaryColor: () => '#111111',
selectMascotColor: () => 'blue',
}));
vi.mock('../Conversations', () => ({
default: ({ variant }: { variant: string }) => <div data-testid="conversations">{variant}</div>,
AgentChatPanel: () => <div data-testid="agent-chat-panel" />,
}));
describe('Accounts provider selection', () => {
beforeEach(() => {
vi.clearAllMocks();
mockState = agentState;
});
it('mounts the routed agent chat panel while the agent is selected', () => {
renderAccounts();
expect(screen.getByTestId('agent-chat-panel')).toBeInTheDocument();
});
it('does not mount the routed agent chat panel while a provider is selected', () => {
mockState = {
...agentState,
accounts: { ...agentState.accounts, activeAccountId: 'acct-whatsapp' },
};
renderAccounts();
expect(screen.queryByTestId('agent-chat-panel')).not.toBeInTheDocument();
});
it('selects the agent account for thread routes', () => {
renderAccounts('/chat/thread-route-1');
expect(mockDispatch).toHaveBeenCalledWith({
type: 'accounts/setActiveAccount',
payload: '__agent__',
});
});
});
function renderAccounts(route = '/chat') {
return render(
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route path="/chat/:threadId?" element={<Accounts />} />
</Routes>
</MemoryRouter>
);
}
@@ -10,7 +10,7 @@
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SidebarSlotOutlet, SidebarSlotProvider } from '../../components/layout/shell/SidebarSlot';
@@ -221,6 +221,70 @@ async function renderConversations(preload: Record<string, unknown> = {}) {
return store;
}
async function renderConversationsRoute(route: string, preload: Record<string, unknown> = {}) {
const store = buildStore(preload);
const { default: Conversations } = await import('../Conversations');
render(
<Provider store={store}>
<MemoryRouter initialEntries={[route]}>
<SidebarSlotProvider>
<SidebarSlotOutlet />
<Routes>
<Route
path="/chat/:threadId?"
element={
<>
<LocationProbe />
<Conversations />
</>
}
/>
</Routes>
</SidebarSlotProvider>
</MemoryRouter>
</Provider>
);
return store;
}
async function renderEmbeddedConversationsRoute(
route: string,
preload: Record<string, unknown> = {}
) {
const store = buildStore(preload);
const { default: Conversations } = await import('../Conversations');
render(
<Provider store={store}>
<MemoryRouter initialEntries={[route]}>
<SidebarSlotProvider>
<SidebarSlotOutlet />
<Routes>
<Route
path="/human"
element={
<>
<LocationProbe />
<Conversations variant="sidebar" composer="mic-cloud" projectThreadList />
</>
}
/>
</Routes>
</SidebarSlotProvider>
</MemoryRouter>
</Provider>
);
return store;
}
function LocationProbe() {
const location = useLocation();
return <span data-testid="route-path">{location.pathname}</span>;
}
/** The thread sidebar is always projected now (no toggle); just flush effects. */
async function openSidebar() {
await act(async () => {});
@@ -376,6 +440,81 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
expect(screen.getAllByText('Thread Beta').length).toBeGreaterThan(0);
});
it('falls back to /chat when the routed thread id is missing', async () => {
mockGetThreads.mockResolvedValue({
threads: [makeThread({ id: 't-1', title: 'Thread Alpha' })],
count: 1,
});
await act(async () => {
await renderConversationsRoute('/chat/missing-thread', { thread: emptyThreadState });
});
await waitFor(() => {
expect(screen.getByTestId('route-path')).toHaveTextContent('/chat');
});
expect(threadApi.createNewThread).not.toHaveBeenCalled();
});
it('updates the route when selecting sidebar threads by click or keyboard', async () => {
const threads = [
makeThread({ id: 't-1', title: 'Thread Alpha' }),
makeThread({ id: 't-2', title: 'Thread Beta' }),
];
mockGetThreads.mockResolvedValue({ threads, count: 2 });
await act(async () => {
await renderConversationsRoute('/chat', { thread: emptyThreadState });
});
await openSidebar();
const alphaRow = await screen.findByRole('button', { name: /Thread Alpha/ });
await act(async () => {
fireEvent.click(alphaRow);
});
await waitFor(() => {
expect(screen.getByTestId('route-path')).toHaveTextContent('/chat/t-1');
});
const betaRow = await screen.findByRole('button', { name: /Thread Beta/ });
await act(async () => {
fireEvent.keyDown(betaRow, { key: 'Enter' });
});
await waitFor(() => {
expect(screen.getByTestId('route-path')).toHaveTextContent('/chat/t-2');
});
});
it('does not push chat routes when embedded chat creates a thread', async () => {
mockGetThreads.mockResolvedValue({ threads: [], count: 0 });
await act(async () => {
await renderEmbeddedConversationsRoute('/human', { thread: emptyThreadState });
});
await waitFor(() => {
expect(threadApi.createNewThread).toHaveBeenCalled();
});
expect(screen.getByTestId('route-path')).toHaveTextContent('/human');
});
it('does not push chat routes when embedded chat selects a thread', async () => {
const threads = [makeThread({ id: 't-1', title: 'Thread Alpha' })];
mockGetThreads.mockResolvedValue({ threads, count: 1 });
await act(async () => {
await renderEmbeddedConversationsRoute('/human', { thread: emptyThreadState });
});
await openSidebar();
const alphaRow = await screen.findByRole('button', { name: /Thread Alpha/ });
await act(async () => {
fireEvent.click(alphaRow);
});
expect(screen.getByTestId('route-path')).toHaveTextContent('/human');
});
// Covers line 1083: messagesError branch renders error state
it('renders the error icon section when loadThreadMessages rejects', async () => {
// Make loadThreadMessages always fail so messagesError is set in the store
@@ -635,6 +774,27 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
expect(screen.getByText(/Are you sure you want to delete/i)).toBeInTheDocument();
});
it('replaces the route when deleting the currently-routed thread', async () => {
const thread = makeThread({ id: 't-del', title: 'Deletable Thread' });
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
await act(async () => {
await renderConversationsRoute('/chat/t-del', { thread: selectedThreadState(thread) });
});
await openSidebar();
const deleteBtn = await screen.findByTitle('Delete thread');
await act(async () => {
fireEvent.click(deleteBtn);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
});
await waitFor(() => expect(threadApi.deleteThread).toHaveBeenCalledWith('t-del'));
expect(screen.getByTestId('route-path')).toHaveTextContent('/chat');
});
// Covers lines 1399, 1409-1410: isNearLimit UpsellBanner render + onCtaClick
it('renders near-limit UpsellBanner and clicking Upgrade calls openUrl', async () => {
const { openUrl } = await import('../../utils/openUrl');
@@ -1626,7 +1786,7 @@ describe('Conversations — open-session resume (View work)', () => {
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
});
it('honours location.state.openThreadId to open a task session on mount', async () => {
it('honours /chat/:threadId to open a task session on mount', async () => {
// A task-labelled session thread, reachable only via an explicit
// open-intent because it's hidden behind the default General tab.
const taskThread = makeThread({
@@ -1642,11 +1802,10 @@ describe('Conversations — open-session resume (View work)', () => {
await act(async () => {
render(
<Provider store={store}>
<MemoryRouter
initialEntries={[
{ pathname: '/conversations', state: { openThreadId: 'task-open-1' } },
]}>
<Conversations />
<MemoryRouter initialEntries={['/chat/task-open-1']}>
<Routes>
<Route path="/chat/:threadId" element={<Conversations />} />
</Routes>
</MemoryRouter>
</Provider>
);
@@ -1709,4 +1868,44 @@ describe('Conversations — open-session resume (View work)', () => {
// onViewSession navigates the chat view to the card's session thread.
await waitFor(() => expect(store.getState().thread.selectedThreadId).toBe('sess-99'));
});
it('does not push chat routes when embedded chat opens task session work', async () => {
const thread = makeThread({ id: 'board-thread', title: 'Board thread' });
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
const store = await renderEmbeddedConversationsRoute('/human', {
thread: selectedThreadState(thread),
});
const selectedId = store.getState().thread.selectedThreadId ?? 'board-thread';
await act(async () => {
store.dispatch(
setTaskBoardForThread({
threadId: selectedId,
board: {
threadId: selectedId,
updatedAt: '',
cards: [
{
id: 'tc1',
title: 'Worked card',
status: 'in_progress',
order: 0,
updatedAt: '',
sessionThreadId: 'sess-99',
},
],
},
})
);
});
const viewBtn = await screen.findByTitle('View work');
await act(async () => {
fireEvent.click(viewBtn);
});
await waitFor(() => expect(store.getState().thread.selectedThreadId).toBe('sess-99'));
expect(screen.getByTestId('route-path')).toHaveTextContent('/human');
});
});
@@ -5,6 +5,7 @@ import { store } from '../../store';
import { addAccount, resetAccountsState } from '../../store/accountsSlice';
import {
closeWebviewAccount,
hideWebviewAccount,
openWebviewAccount,
retryWebviewAccountLoad,
setWebviewAccountBounds,
@@ -219,6 +220,22 @@ describe('webviewAccountService load listener', () => {
expect(vi.mocked(invoke)).not.toHaveBeenCalled();
});
it('skips reveal when a load finishes after the host hides during an overlay', async () => {
const bounds = { x: 5, y: 15, width: 1024, height: 768 };
await openWebviewAccount({ accountId: ACCOUNT_ID, provider: 'telegram', bounds });
vi.mocked(invoke).mockClear();
await hideWebviewAccount(ACCOUNT_ID);
expect(vi.mocked(invoke)).toHaveBeenCalledWith('webview_account_hide', {
args: { account_id: ACCOUNT_ID },
});
vi.mocked(invoke).mockClear();
await fireLoadEvent({ state: 'finished', url: 'https://web.telegram.org/' });
expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('webview_account_reveal', expect.anything());
});
it('retry re-opens with cached bounds and provider', async () => {
const bounds = { x: 0, y: 0, width: 920, height: 700 };
await openWebviewAccount({ accountId: ACCOUNT_ID, provider: 'telegram', bounds });
@@ -1368,6 +1368,12 @@ export async function setWebviewAccountBounds(
export async function hideWebviewAccount(accountId: string): Promise<void> {
if (!isTauri()) return;
// Hide is used when the React host unmounts for route changes or shell
// overlays. Clear reveal-eligible state before the IPC so a late
// `webview-account:load` event cannot reveal the native CEF view over UI
// that no longer owns it.
lastBoundsByAccount.delete(accountId);
loadingAccounts.delete(accountId);
try {
await invoke('webview_account_hide', { args: { account_id: accountId } });
} catch (err) {
+3 -2
View File
@@ -144,8 +144,9 @@ const accountsSlice = createSlice({
/**
* Signals that a rail overlay (add-account modal / context menu) opened or
* closed. Read by the chat page to hide/restore the active provider webview,
* which composites above HTML and would otherwise paint over the overlay.
* closed. Read by the desktop shell to hide/restore the active provider
* webview, which composites above HTML and would otherwise paint over the
* overlay.
*/
setAccountsOverlayOpen(state, action: PayloadAction<boolean>) {
state.overlayOpen = action.payload;
+1 -1
View File
@@ -116,7 +116,7 @@ const persistedChannelConnectionsReducer = persistReducer(
// Issue #2044 — `activeAccountId` is deliberately NOT persisted. It is a
// per-session UX selection: persisting it caused provider webviews to
// auto-surface on dev hot reload / app restart without an explicit user
// click, because `Accounts.tsx` immediately mounts `WebviewHost` for the
// click, because the desktop shell immediately mounts `WebviewHost` for the
// active account and `WebviewHost` calls `openWebviewAccount` on mount.
// `lastActiveAccountId` is still persisted so the off-screen MRU prewarm
// can warm the same account in the background — that webview stays
+2
View File
@@ -12,6 +12,7 @@ import { MemoryRouter } from 'react-router-dom';
import { SidebarSlotOutlet, SidebarSlotProvider } from '../components/layout/shell/SidebarSlot';
import { getCoreStateSnapshot } from '../lib/coreState/store';
import { CoreStateContext } from '../providers/coreStateContext';
import accountsReducer from '../store/accountsSlice';
import backendMeetReducer from '../store/backendMeetSlice';
import channelConnectionsReducer from '../store/channelConnectionsSlice';
import companionReducer from '../store/companionSlice';
@@ -38,6 +39,7 @@ import themeReducer from '../store/themeSlice';
* meeting status from this slice.
*/
const testRootReducer = combineReducers({
accounts: accountsReducer,
backendMeet: backendMeetReducer,
channelConnections: channelConnectionsReducer,
companion: companionReducer,
+3
View File
@@ -0,0 +1,3 @@
export function chatThreadPath(threadId: string): string {
return `/chat/${encodeURIComponent(threadId)}`;
}