mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(chat): decouple provider webviews from thread routes (#3896)
This commit is contained in:
+32
-7
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function chatThreadPath(threadId: string): string {
|
||||
return `/chat/${encodeURIComponent(threadId)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user