- {t('accounts.noAccounts')}
+ {isAgentSelected && (
+
)}
diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx
index edb97d084..e47860af1 100644
--- a/app/src/pages/Conversations.tsx
+++ b/app/src/pages/Conversations.tsx
@@ -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">
diff --git a/app/src/pages/__tests__/Accounts.webviewSelection.test.tsx b/app/src/pages/__tests__/Accounts.webviewSelection.test.tsx
new file mode 100644
index 000000000..3e0786edd
--- /dev/null
+++ b/app/src/pages/__tests__/Accounts.webviewSelection.test.tsx
@@ -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 }) =>
{variant}
,
+ AgentChatPanel: () =>
,
+}));
+
+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(
+
+
+ } />
+
+
+ );
+}
diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx
index 6920b1d6c..1b447656a 100644
--- a/app/src/pages/__tests__/Conversations.render.test.tsx
+++ b/app/src/pages/__tests__/Conversations.render.test.tsx
@@ -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
= {}) {
return store;
}
+async function renderConversationsRoute(route: string, preload: Record = {}) {
+ const store = buildStore(preload);
+ const { default: Conversations } = await import('../Conversations');
+
+ render(
+
+
+
+
+
+
+
+
+ >
+ }
+ />
+
+
+
+
+ );
+
+ return store;
+}
+
+async function renderEmbeddedConversationsRoute(
+ route: string,
+ preload: Record = {}
+) {
+ const store = buildStore(preload);
+ const { default: Conversations } = await import('../Conversations');
+
+ render(
+
+
+
+
+
+
+
+
+ >
+ }
+ />
+
+
+
+
+ );
+
+ return store;
+}
+
+function LocationProbe() {
+ const location = useLocation();
+ return {location.pathname};
+}
+
/** 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(
-
-
+
+
+ } />
+
);
@@ -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');
+ });
});
diff --git a/app/src/services/__tests__/webviewAccountService.loadListener.test.ts b/app/src/services/__tests__/webviewAccountService.loadListener.test.ts
index 840121604..b875788eb 100644
--- a/app/src/services/__tests__/webviewAccountService.loadListener.test.ts
+++ b/app/src/services/__tests__/webviewAccountService.loadListener.test.ts
@@ -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 });
diff --git a/app/src/services/webviewAccountService.ts b/app/src/services/webviewAccountService.ts
index 4954294b8..d16ad75db 100644
--- a/app/src/services/webviewAccountService.ts
+++ b/app/src/services/webviewAccountService.ts
@@ -1368,6 +1368,12 @@ export async function setWebviewAccountBounds(
export async function hideWebviewAccount(accountId: string): Promise {
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) {
diff --git a/app/src/store/accountsSlice.ts b/app/src/store/accountsSlice.ts
index 34b8fc7d9..2a3398fd0 100644
--- a/app/src/store/accountsSlice.ts
+++ b/app/src/store/accountsSlice.ts
@@ -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) {
state.overlayOpen = action.payload;
diff --git a/app/src/store/index.ts b/app/src/store/index.ts
index 846eccf34..3427dbb7f 100644
--- a/app/src/store/index.ts
+++ b/app/src/store/index.ts
@@ -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
diff --git a/app/src/test/test-utils.tsx b/app/src/test/test-utils.tsx
index 2c437bd2c..0464ba753 100644
--- a/app/src/test/test-utils.tsx
+++ b/app/src/test/test-utils.tsx
@@ -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,
diff --git a/app/src/utils/chatRoutes.ts b/app/src/utils/chatRoutes.ts
new file mode 100644
index 000000000..c7ae78801
--- /dev/null
+++ b/app/src/utils/chatRoutes.ts
@@ -0,0 +1,3 @@
+export function chatThreadPath(threadId: string): string {
+ return `/chat/${encodeURIComponent(threadId)}`;
+}