feat(settings): present desktop Settings as a full-app modal overlay (#4002)

This commit is contained in:
Cyrus Gray
2026-06-23 21:46:48 +05:30
committed by GitHub
parent 3c98c98158
commit 3a2b38d0c3
40 changed files with 871 additions and 304 deletions
+12 -1
View File
@@ -28,6 +28,8 @@ import OpenhumanLinkModal from './components/OpenhumanLinkModal';
import PersistRehydrationScreen from './components/PersistRehydrationScreen';
import PttHotkeyManager from './components/PttHotkeyManager';
import SecurityBanner from './components/SecurityBanner';
import SettingsModal from './components/settings/modal/SettingsModal';
import { resolveSettingsOverlay } from './components/settings/modal/settingsOverlay';
import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner';
import AppWalkthrough from './components/walkthrough/AppWalkthrough';
import { MascotFrameProducer } from './features/meet/MascotFrameProducer';
@@ -244,6 +246,12 @@ export function AppShellDesktop() {
);
const chromeless = !token || onOnboardingRoute || onHiddenChromePath;
// Desktop Settings is a modal overlay (the backgroundLocation pattern): when
// the URL is a settings path we keep rendering the page *behind* it
// (`baseLocation`) and mount <SettingsModal/> on top (z-50 portal), which sits
// above the provider WebviewHost overlay (z-30) below.
const { settingsOpen, baseLocation } = resolveSettingsOverlay(location);
const activeProviderAccount =
activeAccountId && activeAccountId !== AGENT_ACCOUNT_ID
? (accountsById[activeAccountId] ?? null)
@@ -252,7 +260,7 @@ export function AppShellDesktop() {
const content = (
<div ref={scrollRef} className="relative h-full overflow-y-auto">
<GlobalUpsellBanner />
<AppRoutes />
<AppRoutes location={baseLocation} />
{activeProviderAccount && !accountsOverlayOpen && (
<div className="absolute inset-0 z-30">
<WebviewHost
@@ -275,6 +283,9 @@ export function AppShellDesktop() {
<RootShellLayout sidebar={<AppSidebar />}>{content}</RootShellLayout>
)}
</div>
{/* Desktop Settings modal — mounted over whatever page is rendered
beneath when the URL is a settings path. */}
{settingsOpen && !chromeless && <SettingsModal />}
<OpenhumanLinkModal />
{/* Hidden Remotion-driven producer for the Meet camera. Mounts a
640×480 JPEG frame stream to the Rust frame bus while a meet
+17 -12
View File
@@ -1,4 +1,4 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { type Location, Navigate, Route, Routes } from 'react-router-dom';
import AgentWorldShell from './agentworld/AgentWorldShell';
import AgentWorld from './agentworld/pages/AgentWorld';
@@ -17,14 +17,23 @@ import Notifications from './pages/Notifications';
import Onboarding from './pages/onboarding/Onboarding';
import { PttOverlayPage } from './pages/PttOverlayPage';
import Rewards from './pages/Rewards';
import Settings from './pages/Settings';
import Skills from './pages/Skills';
import WebCallbackPage from './pages/WebCallbackPage';
import Welcome from './pages/Welcome';
import WorkflowNew from './pages/WorkflowNew';
import WorkflowsRun from './pages/WorkflowsRun';
const AppRoutes = () => {
interface AppRoutesProps {
/**
* Optional location override. The desktop shell passes the *background*
* location here while the Settings modal is open, so the page behind the
* modal stays rendered even though the URL is `/settings/*`. Omitted
* everywhere else (router uses the ambient location).
*/
location?: Location | string;
}
const AppRoutes = ({ location }: AppRoutesProps = {}) => {
// Mobile target (iOS or Android): pair → Human/Chat/Settings only.
// Desktop routes are not rendered.
if (getIsMobile()) {
@@ -32,7 +41,7 @@ const AppRoutes = () => {
}
return (
<Routes>
<Routes location={location}>
{/* Public routes - redirect to /home if logged in */}
<Route
path="/"
@@ -187,14 +196,10 @@ const AppRoutes = () => {
<Route path="/webhooks" element={<Navigate to="/settings/integrations#webhooks" replace />} />
<Route
path="/settings/*"
element={
<ProtectedRoute requireAuth={true}>
<Settings />
</ProtectedRoute>
}
/>
{/* Desktop Settings renders as a modal overlay mounted by AppShellDesktop
(App.tsx) using the backgroundLocation pattern — it is no longer an
inline route here. iOS keeps its own /settings/* route in
AppRoutesIOS.tsx. */}
<Route path="/ptt-overlay" element={<PttOverlayPage />} />
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useT } from '../../lib/i18n/I18nContext';
import type { SubconsciousMode } from '../../utils/tauriCommands/heartbeat';
import type { SubconsciousStatus } from '../../utils/tauriCommands/subconscious';
import { settingsNavState } from '../settings/modal/settingsOverlay';
interface ModeOption {
id: SubconsciousMode;
@@ -67,6 +68,7 @@ export default function IntelligenceSubconsciousTab({
}: IntelligenceSubconsciousTabProps) {
const { t } = useT();
const navigate = useNavigate();
const location = useLocation();
const providerUnavailable = status?.provider_available === false;
const providerUnavailableReason = providerUnavailable
? (status?.provider_unavailable_reason ?? t('subconscious.providerUnavailableTitle'))
@@ -238,7 +240,7 @@ export default function IntelligenceSubconsciousTab({
</div>
<button
type="button"
onClick={() => navigate('/settings/llm')}
onClick={() => navigate('/settings/llm', settingsNavState(location))}
className="flex-shrink-0 rounded-md bg-amber-600 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-amber-700 transition-colors">
{t('subconscious.providerSettings')}
</button>
@@ -24,7 +24,7 @@ import {
LuSparkles,
LuX,
} from 'react-icons/lu';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useT } from '../../lib/i18n/I18nContext';
import { TaskKanbanBoard } from '../../pages/conversations/components/TaskKanbanBoard';
@@ -48,6 +48,7 @@ import {
import type { ThreadMessage } from '../../types/thread';
import type { TaskBoard, TaskBoardCard, TaskBoardCardStatus } from '../../types/turnState';
import { chatThreadPath } from '../../utils/chatRoutes';
import { settingsNavState } from '../settings/modal/settingsOverlay';
import { UserTaskComposer } from './UserTaskComposer';
const log = debug('intelligence:tasks');
@@ -779,6 +780,7 @@ function TaskSourceTaskList({
}) {
const { t } = useT();
const navigate = useNavigate();
const location = useLocation();
const sortedCards = useMemo(
() => [...board.cards].sort((a, b) => a.order - b.order),
[board.cards]
@@ -797,7 +799,7 @@ function TaskSourceTaskList({
</div>
<button
type="button"
onClick={() => navigate('/settings/integrations')}
onClick={() => navigate('/settings/integrations', settingsNavState(location))}
className="text-xs font-medium text-ocean-600 hover:text-ocean-700 dark:text-ocean-300 dark:hover:text-ocean-200">
{t('conversations.taskKanban.sources.manage')}
</button>
@@ -9,7 +9,16 @@ import IntelligenceSubconsciousTab from '../IntelligenceSubconsciousTab';
const mockNavigate = vi.fn();
vi.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate }));
vi.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
useLocation: () => ({
pathname: '/intelligence',
search: '',
hash: '',
state: null,
key: 'test',
}),
}));
function baseProps(): ComponentProps<typeof IntelligenceSubconsciousTab> {
return {
@@ -67,7 +67,17 @@ vi.mock('../../../store/hooks', () => ({
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return { ...actual, useNavigate: () => hoisted.navigate };
return {
...actual,
useNavigate: () => hoisted.navigate,
useLocation: () => ({
pathname: '/intelligence',
search: '',
hash: '',
state: null,
key: 'test',
}),
};
});
// Stub the composer so we can drive its `onCreated` callback without
@@ -276,7 +286,7 @@ describe('IntelligenceTasksTab', () => {
// "Manage sources" jumps to the merged Integrations settings page
// (task-sources was folded into /settings/integrations).
fireEvent.click(screen.getByText('Manage sources'));
expect(hoisted.navigate).toHaveBeenCalledWith('/settings/integrations');
expect(hoisted.navigate).toHaveBeenCalledWith('/settings/integrations', expect.anything());
});
test('refines a source task and approves it into the personal agent board', async () => {
@@ -37,7 +37,11 @@ describe('CollapsedNavRail', () => {
it('wallet button navigates to /settings/wallet-balances', () => {
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
fireEvent.click(screen.getByRole('button', { name: 'nav.wallet' }));
expect(mockNavigate).toHaveBeenCalledWith('/settings/wallet-balances');
// Carries the backgroundLocation so the desktop Settings modal renders over
// the page it was opened from.
expect(mockNavigate).toHaveBeenCalledWith('/settings/wallet-balances', {
state: { backgroundLocation: expect.objectContaining({ pathname: '/home' }) },
});
});
it('wallet button has correct data-analytics-id', () => {
@@ -6,6 +6,7 @@ import { useT } from '../../../lib/i18n/I18nContext';
import { trackEvent } from '../../../services/analytics';
import { useAppSelector } from '../../../store/hooks';
import { selectUnreadCount } from '../../../store/notificationSlice';
import { settingsNavState } from '../../settings/modal/settingsOverlay';
import { Tooltip } from '../../ui';
import { NavIcon } from './navIcons';
import { useHomeNav } from './useHomeNav';
@@ -78,7 +79,7 @@ export default function CollapsedNavRail() {
<Tooltip label={t('nav.wallet')}>
<button
type="button"
onClick={() => navigate('/settings/wallet-balances')}
onClick={() => navigate('/settings/wallet-balances', settingsNavState(location))}
aria-label={t('nav.wallet')}
aria-current={
matchActive('/settings/wallet-balances', location.pathname) ? 'page' : undefined
@@ -31,7 +31,11 @@ describe('SidebarHeader', () => {
it('wallet button navigates to /settings/wallet-balances', () => {
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
fireEvent.click(screen.getByRole('button', { name: 'nav.wallet' }));
expect(mockNavigate).toHaveBeenCalledWith('/settings/wallet-balances');
// Carries the backgroundLocation so the desktop Settings modal renders over
// the page it was opened from.
expect(mockNavigate).toHaveBeenCalledWith('/settings/wallet-balances', {
state: { backgroundLocation: expect.objectContaining({ pathname: '/home' }) },
});
});
it('wallet button has correct data-analytics-id', () => {
@@ -54,7 +58,9 @@ describe('SidebarHeader', () => {
it('settings button navigates to /settings', () => {
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
fireEvent.click(screen.getByRole('button', { name: 'nav.settings' }));
expect(mockNavigate).toHaveBeenCalledWith('/settings');
expect(mockNavigate).toHaveBeenCalledWith('/settings', {
state: { backgroundLocation: expect.objectContaining({ pathname: '/home' }) },
});
});
it('Home button invokes the shared Home action', () => {
@@ -1,6 +1,7 @@
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { settingsNavState } from '../../settings/modal/settingsOverlay';
import { Tooltip } from '../../ui';
import { useRootSidebar } from './RootShellLayout';
import { useHomeNav } from './useHomeNav';
@@ -16,6 +17,7 @@ const ICON_BTN =
export default function SidebarHeader() {
const { t } = useT();
const navigate = useNavigate();
const location = useLocation();
const { hide } = useRootSidebar();
const handleHome = useHomeNav();
@@ -44,7 +46,7 @@ export default function SidebarHeader() {
<Tooltip label={t('nav.wallet')}>
<button
type="button"
onClick={() => navigate('/settings/wallet-balances')}
onClick={() => navigate('/settings/wallet-balances', settingsNavState(location))}
className={ICON_BTN}
data-analytics-id="sidebar-header-wallet"
aria-label={t('nav.wallet')}>
@@ -62,7 +64,7 @@ export default function SidebarHeader() {
<Tooltip label={t('nav.settings')}>
<button
type="button"
onClick={() => navigate('/settings')}
onClick={() => navigate('/settings', settingsNavState(location))}
className={ICON_BTN}
data-analytics-id="sidebar-header-settings"
aria-label={t('nav.settings')}>
@@ -3,8 +3,9 @@
// every registered route resolves without a parallel switch-statement.
import debug from 'debug';
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { type To, useLocation, useNavigate } from 'react-router-dom';
import { settingsNavState } from '../modal/settingsOverlay';
import { entryRoute, findEntryByRoute, SETTINGS_ROUTE_REGISTRY } from '../settingsRouteRegistry';
const log = debug('settings:nav');
@@ -163,20 +164,18 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
const navigateToSettings = useCallback(
(route: SettingsRoute | string = 'home') => {
if (route === 'home') {
navigate('/settings');
} else {
navigate(`/settings/${route}`);
}
// Preserve the modal's backdrop (desktop) across panel-to-panel nav.
const target = route === 'home' ? '/settings' : `/settings/${route}`;
navigate(target, settingsNavState(location));
},
[navigate]
[navigate, location]
);
const navigateToTeamManagement = useCallback(
(teamId: string) => {
navigate(`/settings/team/manage/${teamId}`);
navigate(`/settings/team/manage/${teamId}`, settingsNavState(location));
},
[navigate]
[navigate, location]
);
const navigateBack = useCallback(() => {
@@ -188,8 +187,12 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
}, [currentRoute, goBackWithFallback]);
const closeSettings = useCallback(() => {
goBackWithFallback('/home');
}, [goBackWithFallback]);
// On desktop the modal was opened over a page (backgroundLocation); return
// there. Otherwise fall back to /home.
const background = (location.state as { backgroundLocation?: To } | null)?.backgroundLocation;
// replace so pressing Back after closing doesn't reopen the Settings modal.
navigate(background ?? '/home', { replace: true });
}, [navigate, location.state]);
// -------------------------------------------------------------------------
// Breadcrumbs — derived from the registry.
@@ -1,4 +1,4 @@
import { Navigate } from 'react-router-dom';
import { Navigate, useLocation } from 'react-router-dom';
import { useMediaQuery } from '../../../hooks/useMediaQuery';
@@ -14,7 +14,10 @@ import { useMediaQuery } from '../../../hooks/useMediaQuery';
*/
const SettingsIndexRedirect = () => {
const isWide = useMediaQuery('(min-width: 768px)');
if (isWide) return <Navigate to="/settings/account" replace />;
const location = useLocation();
// Preserve nav state (notably the desktop modal's `backgroundLocation`) so the
// index redirect doesn't drop the backdrop. Harmless on iOS.
if (isWide) return <Navigate to="/settings/account" replace state={location.state} />;
return null;
};
@@ -0,0 +1,22 @@
import { SettingsModalFrame } from './SettingsModalFrame';
import SettingsModalLayout from './SettingsModalLayout';
import { useCloseSettings } from './useCloseSettings';
/**
* Desktop Settings, presented as a centered full-app-size modal floating over
* the page it was opened from (the "background location"). Composes the
* presentational {@link SettingsModalFrame} (backdrop / Esc / focus / close)
* around the routed two-column {@link SettingsModalLayout}.
*
* Mounted by `AppShellDesktop` whenever the current path is a settings path; the
* shell renders the background page underneath. iOS keeps the full-page
* `pages/Settings.tsx` and never mounts this.
*/
export default function SettingsModal() {
const close = useCloseSettings();
return (
<SettingsModalFrame onClose={close}>
<SettingsModalLayout />
</SettingsModalFrame>
);
}
@@ -0,0 +1,62 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { SettingsModalFrame } from './SettingsModalFrame';
// Identity translator so we can assert on stable i18n keys.
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
describe('SettingsModalFrame', () => {
it('renders its children', () => {
render(
<SettingsModalFrame onClose={vi.fn()}>
<div data-testid="child">hello</div>
</SettingsModalFrame>
);
expect(screen.getByTestId('child')).toHaveTextContent('hello');
});
it('calls onClose when the X button is clicked', () => {
const onClose = vi.fn();
render(
<SettingsModalFrame onClose={onClose}>
<div>body</div>
</SettingsModalFrame>
);
fireEvent.click(screen.getByTestId('settings-modal-close'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when Escape is pressed', () => {
const onClose = vi.fn();
render(
<SettingsModalFrame onClose={onClose}>
<div>body</div>
</SettingsModalFrame>
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when the backdrop is clicked', () => {
const onClose = vi.fn();
render(
<SettingsModalFrame onClose={onClose}>
<div>body</div>
</SettingsModalFrame>
);
fireEvent.click(screen.getByTestId('settings-modal-backdrop'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('does not call onClose when the card body is clicked', () => {
const onClose = vi.fn();
render(
<SettingsModalFrame onClose={onClose}>
<div data-testid="child">body</div>
</SettingsModalFrame>
);
fireEvent.click(screen.getByTestId('child'));
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,79 @@
import { type ReactNode, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useEscapeKey } from '../../../hooks/useEscapeKey';
import { useT } from '../../../lib/i18n/I18nContext';
import { CloseIcon } from '../../ui/icons';
interface SettingsModalFrameProps {
/** Invoked on X click, Esc, or backdrop click. */
onClose: () => void;
children: ReactNode;
/** id of the element labelling the dialog, if any. */
labelledBy?: string;
}
/**
* Presentational chrome for the desktop Settings modal: a portalled, dimmed
* backdrop and a centered, full-app-size card with a floating close button.
*
* Purely presentational — it owns no routing/state so it can be unit-tested in
* isolation. Reuses the same primitives as {@link ModalShell} (Esc handling,
* focus restore, `createPortal`, `CloseIcon`) but lays the card out as a flex
* container for the two-column body, with the close affordance floated in the
* top-right corner instead of a title bar.
*/
export function SettingsModalFrame({ onClose, children, labelledBy }: SettingsModalFrameProps) {
const { t } = useT();
const dialogRef = useRef<HTMLDivElement>(null);
useEscapeKey(onClose);
useEffect(() => {
const previousFocus = document.activeElement as HTMLElement | null;
dialogRef.current?.focus();
return () => previousFocus?.focus?.();
}, []);
// Portal into #root (not document.body) so the modal stays inside the app's
// tested subtree — `#root`-scoped checks (and E2E specs reading
// `#root.innerText()`) see the routed panel. Falls back to body if absent.
const portalTarget = document.getElementById('root') ?? document.body;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
data-testid="settings-modal-backdrop"
onClick={event => {
if (event.target === event.currentTarget) onClose();
}}>
{/* Positioning wrapper sized to the card. The close button is a sibling of
the card so it can float just above the top-right corner — outside the
card surface — and never overlap panel content. No overflow clip here. */}
<div
className="relative mx-4 flex h-[80vh] w-full max-w-5xl"
onClick={event => event.stopPropagation()}>
<button
type="button"
aria-label={t('common.close')}
data-testid="settings-modal-close"
onClick={onClose}
className="absolute bottom-full right-0 mb-2 flex h-8 w-8 items-center justify-center rounded-full border border-stone-200 bg-white text-stone-500 shadow-md transition-colors hover:bg-stone-100 hover:text-stone-700 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-200">
<CloseIcon className="h-4 w-4" />
</button>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : t('nav.settings')}
tabIndex={-1}
data-testid="settings-modal-card"
className="flex h-full w-full overflow-hidden rounded-2xl bg-white shadow-xl animate-fade-up focus:outline-none dark:bg-neutral-900">
{children}
</div>
</div>
</div>,
portalTarget
);
}
@@ -0,0 +1,48 @@
import { Outlet, Route, Routes } from 'react-router-dom';
import { SettingsLayoutProvider } from '../layout/SettingsLayoutContext';
import SettingsSidebar from '../layout/SettingsSidebar';
import SettingsSubNav from '../layout/SettingsSubNav';
import { settingsRouteElements } from '../settingsRouteElements';
/**
* Two-column body of the desktop Settings modal: the grouped nav + search on the
* left and the routed panel on the right, both inside the modal card.
*
* Unlike {@link SettingsLayout} (the iOS full-page host, which projects the
* sidebar into the app shell via `SidebarContent`), this renders the sidebar
* inline. It still advertises `inTwoPaneShell: true` so shared chrome
* (SettingsHeader / SettingsBackButton) behaves exactly as in the full-page
* shell — top-level panels hide their back button and rely on the sidebar.
*
* The shared {@link settingsRouteElements} use paths relative to `/settings`
* (e.g. `account`). On iOS they are nested under the `/settings/*` route in
* `AppRoutesIOS`; here there is no such ancestor (the modal is mounted directly
* by the shell), so we scope them under an explicit `/settings` parent route
* whose `<Outlet/>` renders the matched panel into the right column.
*/
export default function SettingsModalLayout() {
return (
<SettingsLayoutProvider value={{ inTwoPaneShell: true }}>
<div className="flex h-full w-full min-h-0">
<div className="h-full w-64 flex-shrink-0 overflow-hidden border-r border-stone-200 dark:border-neutral-800">
<SettingsSidebar />
</div>
{/* Right column: the sub-nav pill row (Account → Team/Privacy/… ) pinned
above the routed panel, mirroring the iOS full-page SettingsLayout. */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex-shrink-0">
<SettingsSubNav />
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<Routes>
<Route path="/settings" element={<Outlet />}>
{settingsRouteElements()}
</Route>
</Routes>
</div>
</div>
</div>
</SettingsLayoutProvider>
);
}
@@ -0,0 +1,81 @@
import type { Location } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import {
isSettingsPath,
resolveBackgroundLocation,
resolveSettingsOverlay,
SETTINGS_FALLBACK_PATH,
settingsNavState,
} from './settingsOverlay';
/** Build a minimal Location for tests (cast avoids coupling to router internals). */
const loc = (pathname: string, state: unknown = null): Location =>
({ pathname, search: '', hash: '', state, key: 'test' }) as unknown as Location;
describe('isSettingsPath', () => {
it('matches the settings index and sub-paths', () => {
expect(isSettingsPath('/settings')).toBe(true);
expect(isSettingsPath('/settings/account')).toBe(true);
expect(isSettingsPath('/settings/team/manage/1')).toBe(true);
});
it('does not match other paths', () => {
expect(isSettingsPath('/chat')).toBe(false);
expect(isSettingsPath('/settings-foo')).toBe(false);
expect(isSettingsPath('/')).toBe(false);
});
});
describe('resolveBackgroundLocation', () => {
it('captures the current location when opening from a non-settings page', () => {
const here = loc('/brain');
expect(resolveBackgroundLocation(here)).toBe(here);
});
it('preserves the stored background when already on a settings path', () => {
const background = loc('/brain');
expect(
resolveBackgroundLocation(loc('/settings/billing', { backgroundLocation: background }))
).toBe(background);
});
it('returns undefined on a settings path with no stored background (deep link)', () => {
expect(resolveBackgroundLocation(loc('/settings/voice'))).toBeUndefined();
});
});
describe('settingsNavState', () => {
it('wraps the resolved background in navigate options', () => {
const here = loc('/rewards');
expect(settingsNavState(here)).toEqual({ state: { backgroundLocation: here } });
});
it('preserves background across in-modal navigation', () => {
const background = loc('/human');
expect(settingsNavState(loc('/settings/account', { backgroundLocation: background }))).toEqual({
state: { backgroundLocation: background },
});
});
});
describe('resolveSettingsOverlay', () => {
it('reports closed and passes the current location through off settings', () => {
const here = loc('/chat');
expect(resolveSettingsOverlay(here)).toEqual({ settingsOpen: false, baseLocation: here });
});
it('reports open and renders the stored background behind', () => {
const background = loc('/brain');
expect(
resolveSettingsOverlay(loc('/settings/account', { backgroundLocation: background }))
).toEqual({ settingsOpen: true, baseLocation: background });
});
it('falls back to /chat behind a deep-linked settings path', () => {
expect(resolveSettingsOverlay(loc('/settings/voice'))).toEqual({
settingsOpen: true,
baseLocation: SETTINGS_FALLBACK_PATH,
});
});
});
@@ -0,0 +1,79 @@
// [settings:modal] Pure routing helpers for the desktop Settings modal overlay.
//
// Desktop Settings is presented as a centered modal floating over the page the
// user was on. We use react-router's "background location" pattern: opening
// Settings stashes the current location in `history.state.backgroundLocation`,
// the shell renders that page underneath, and the `/settings/*` route renders
// inside the modal on top. The URL still becomes `/settings/account` etc. so
// deep links, settings search, and legacy redirects keep working.
//
// This module is pure (no JSX/React) so the branching logic is unit-testable in
// isolation without mounting the app or importing the panel barrel.
import debugFactory from 'debug';
import type { Location } from 'react-router-dom';
const debug = debugFactory('settings:modal');
/**
* Page rendered behind the modal when Settings is reached via a deep link or a
* redirect that carries no `backgroundLocation` (e.g. `/activity` →
* `/settings/notifications`). Guarantees the overlay always has a sane backdrop.
*/
export const SETTINGS_FALLBACK_PATH = '/chat';
/** Shape of the nav state we thread through history to remember the backdrop. */
interface SettingsLocationState {
backgroundLocation?: Location;
}
/** True when a pathname targets the settings route tree. */
export function isSettingsPath(pathname: string): boolean {
return pathname === '/settings' || pathname.startsWith('/settings/');
}
/**
* Resolve the location that should render *behind* the modal for a navigation.
*
* - Already on a settings path (switching panels inside the modal): preserve the
* existing `backgroundLocation` so the backdrop never changes.
* - Anywhere else (opening Settings): the current location *is* the backdrop.
*
* Returns `undefined` only when we're on a settings path with no stored
* background (deep link / redirect) — callers fall back to {@link
* SETTINGS_FALLBACK_PATH}.
*/
export function resolveBackgroundLocation(location: Location): Location | undefined {
if (isSettingsPath(location.pathname)) {
return (location.state as SettingsLocationState | null)?.backgroundLocation;
}
return location;
}
/**
* `navigate(...)` options that carry the resolved backdrop. Spread into any
* navigation that opens or moves within Settings:
* navigate('/settings/billing', settingsNavState(location))
*/
export function settingsNavState(location: Location): { state: { backgroundLocation?: Location } } {
return { state: { backgroundLocation: resolveBackgroundLocation(location) } };
}
/**
* Drive the desktop shell: whether the Settings modal is open and which
* location the page *behind* it should render.
*/
export function resolveSettingsOverlay(location: Location): {
settingsOpen: boolean;
baseLocation: Location | string;
} {
const settingsOpen = isSettingsPath(location.pathname);
const background = (location.state as SettingsLocationState | null)?.backgroundLocation;
const baseLocation = background ?? (settingsOpen ? SETTINGS_FALLBACK_PATH : location);
debug(
'resolveSettingsOverlay: path=%s open=%s base=%o',
location.pathname,
settingsOpen,
background ?? (settingsOpen ? SETTINGS_FALLBACK_PATH : location.pathname)
);
return { settingsOpen, baseLocation };
}
@@ -0,0 +1,44 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { useCloseSettings } from './useCloseSettings';
/** Probe that closes on click and echoes the current path. */
function Probe() {
const close = useCloseSettings();
const location = useLocation();
return (
<button type="button" data-testid="probe" data-path={location.pathname} onClick={close}>
close
</button>
);
}
const currentPath = () => screen.getByTestId('probe').getAttribute('data-path');
describe('useCloseSettings', () => {
it('navigates to the stored backgroundLocation when present', () => {
render(
<MemoryRouter
initialEntries={[
{ pathname: '/settings/account', state: { backgroundLocation: { pathname: '/brain' } } },
]}>
<Probe />
</MemoryRouter>
);
expect(currentPath()).toBe('/settings/account');
fireEvent.click(screen.getByTestId('probe'));
expect(currentPath()).toBe('/brain');
});
it('falls back to /chat when there is no backgroundLocation', () => {
render(
<MemoryRouter initialEntries={[{ pathname: '/settings/voice' }]}>
<Probe />
</MemoryRouter>
);
fireEvent.click(screen.getByTestId('probe'));
expect(currentPath()).toBe('/chat');
});
});
@@ -0,0 +1,29 @@
import debugFactory from 'debug';
import { useCallback } from 'react';
import { type Location, useLocation, useNavigate } from 'react-router-dom';
import { SETTINGS_FALLBACK_PATH } from './settingsOverlay';
const debug = debugFactory('settings:modal');
interface SettingsLocationState {
backgroundLocation?: Location;
}
/**
* Returns a callback that closes the Settings modal by navigating back to the
* page it was opened over (`history.state.backgroundLocation`), falling back to
* {@link SETTINGS_FALLBACK_PATH} for deep links / redirects that carry no
* background. Shared by the X button, Esc, and backdrop click.
*/
export function useCloseSettings(): () => void {
const navigate = useNavigate();
const location = useLocation();
return useCallback(() => {
const background = (location.state as SettingsLocationState | null)?.backgroundLocation;
debug('closeSettings: background=%o', background ?? SETTINGS_FALLBACK_PATH);
// replace so pressing Back after closing doesn't reopen the Settings modal.
navigate(background ?? SETTINGS_FALLBACK_PATH, { replace: true });
}, [navigate, location.state]);
}
@@ -83,7 +83,7 @@ describe('AgentEditorPage', () => {
expect(arg.id).toBe('helper'); // auto-slugified from name
expect(arg.name).toBe('Helper');
expect(arg.model).toBe('hint:coding');
expect(mockNavigate).toHaveBeenCalledWith('/settings/agents');
expect(mockNavigate).toHaveBeenCalledWith('/settings/agents', expect.anything());
});
it('offers the vision tier + hint as model options', async () => {
@@ -16,7 +16,7 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { LuPlus, LuSearch, LuX } from 'react-icons/lu';
import { useNavigate, useParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import {
@@ -34,6 +34,7 @@ import {
SettingsTextArea,
SettingsTextField,
} from '../controls';
import { settingsNavState } from '../modal/settingsOverlay';
// Known model options — mirrors the Rust tier constants + route hints
// (src/openhuman/config/schema/types.rs, inference/provider/router.rs).
@@ -71,8 +72,12 @@ function slugify(name: string): string {
const AgentEditorPage = () => {
const { t } = useT();
const navigate = useNavigate();
const location = useLocation();
const { id: routeId } = useParams<{ id: string }>();
const backToList = useCallback(() => navigate('/settings/agents'), [navigate]);
const backToList = useCallback(
() => navigate('/settings/agents', settingsNavState(location)),
[navigate, location]
);
const isCreate = !routeId;
const [loading, setLoading] = useState(!isCreate);
@@ -23,8 +23,13 @@ vi.mock('react-router-dom', async importOriginal => {
return { ...actual, useNavigate: () => mockNavigate };
});
const mockNavigateToSettings = vi.fn();
vi.mock('../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
useSettingsNavigation: () => ({
navigateBack: vi.fn(),
navigateToSettings: mockNavigateToSettings,
breadcrumbs: [],
}),
}));
vi.mock('../SettingsHeader', () => ({
@@ -94,7 +99,9 @@ describe('AgentsPanel', () => {
renderPanel();
await waitFor(() => expect(screen.getByText('Researcher')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /New agent/ }));
expect(mockNavigate).toHaveBeenCalledWith('/settings/agents/new');
// Routes through useSettingsNavigation so the desktop modal backdrop is
// preserved; the hook prefixes `/settings/`.
expect(mockNavigateToSettings).toHaveBeenCalledWith('agents/new');
});
it('only offers Edit for custom agents and navigates to the edit page', async () => {
@@ -105,7 +112,7 @@ describe('AgentsPanel', () => {
const editButtons = screen.getAllByRole('button', { name: /Edit/ });
expect(editButtons).toHaveLength(1);
fireEvent.click(editButtons[0]);
expect(mockNavigate).toHaveBeenCalledWith('/settings/agents/edit/finance');
expect(mockNavigateToSettings).toHaveBeenCalledWith('agents/edit/finance');
});
it('shows an error when loading fails', async () => {
@@ -9,7 +9,6 @@
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { LuPencil, LuPlus, LuRotateCcw, LuTrash2 } from 'react-icons/lu';
import { useNavigate } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { agentRegistryApi, type AgentRegistryEntry } from '../../../services/api/agentRegistryApi';
@@ -23,8 +22,7 @@ const ORCHESTRATOR_ID = 'orchestrator';
const AgentsPanel = () => {
const { t } = useT();
const navigate = useNavigate();
const { navigateBack } = useSettingsNavigation();
const { navigateBack, navigateToSettings } = useSettingsNavigation();
const [agents, setAgents] = useState<AgentRegistryEntry[]>([]);
const [loading, setLoading] = useState(true);
@@ -107,7 +105,7 @@ const AgentsPanel = () => {
type="button"
variant="primary"
size="xs"
onClick={() => navigate('/settings/agents/new')}>
onClick={() => navigateToSettings('agents/new')}>
<LuPlus className="h-3.5 w-3.5 mr-1" />
{t('settings.agents.newAgent')}
</Button>
@@ -138,7 +136,7 @@ const AgentsPanel = () => {
agent={agent}
busy={busyId === agent.id}
onToggle={() => handleToggle(agent)}
onEdit={() => navigate(`/settings/agents/edit/${agent.id}`)}
onEdit={() => navigateToSettings(`agents/edit/${agent.id}`)}
onRemove={() => handleRemove(agent)}
/>
))}
@@ -79,7 +79,7 @@ describe('ProfileEditorPage', () => {
expect(sent.id).toBe('my-research');
expect(sent.name).toBe('My Research');
expect(sent.includeAgentConversations).toBe(true);
expect(mockNavigate).toHaveBeenCalledWith('/settings/profiles');
expect(mockNavigate).toHaveBeenCalledWith('/settings/profiles', expect.anything());
});
it('disables Create until a non-empty resolved id exists', () => {
@@ -13,7 +13,7 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { LuX } from 'react-icons/lu';
import { useNavigate, useParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { selectAgentProfiles, upsertAgentProfile } from '../../../store/agentProfileSlice';
@@ -29,6 +29,7 @@ import {
SettingsTextArea,
SettingsTextField,
} from '../controls';
import { settingsNavState } from '../modal/settingsOverlay';
const MODEL_HINTS = ['hint:reasoning', 'hint:chat', 'hint:agentic', 'hint:coding'];
@@ -48,10 +49,14 @@ type Allowlist = string[] | null;
const ProfileEditorPage = () => {
const { t } = useT();
const navigate = useNavigate();
const location = useLocation();
const dispatch = useAppDispatch();
const { id: routeId } = useParams<{ id: string }>();
const profiles = useAppSelector(selectAgentProfiles);
const backToList = useCallback(() => navigate('/settings/profiles'), [navigate]);
const backToList = useCallback(
() => navigate('/settings/profiles', settingsNavState(location)),
[navigate, location]
);
const isCreate = !routeId;
const existing = useMemo(
@@ -88,7 +88,8 @@ describe('ProfilesPanel', () => {
renderPanel();
await screen.findByText('Writer');
fireEvent.click(screen.getByText('New profile'));
expect(mockNavigate).toHaveBeenCalledWith('/settings/profiles/new');
// Second arg carries the backgroundLocation nav state for the desktop modal.
expect(mockNavigate).toHaveBeenCalledWith('/settings/profiles/new', expect.anything());
});
it('sets a non-active profile as active', async () => {
@@ -104,7 +105,10 @@ describe('ProfilesPanel', () => {
renderPanel();
await screen.findByText('Writer');
fireEvent.click(screen.getAllByText('Edit')[0]);
expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/settings/profiles/edit/'));
expect(mockNavigate).toHaveBeenCalledWith(
expect.stringContaining('/settings/profiles/edit/'),
expect.anything()
);
});
it('deletes a custom profile after confirmation', async () => {
@@ -8,7 +8,7 @@
*/
import { useCallback, useEffect, useState } from 'react';
import { LuPlus } from 'react-icons/lu';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import {
@@ -23,10 +23,12 @@ import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsEmptyState, SettingsSection } from '../controls';
import { settingsNavState } from '../modal/settingsOverlay';
const ProfilesPanel = () => {
const { t } = useT();
const navigate = useNavigate();
const location = useLocation();
const dispatch = useAppDispatch();
const profiles = useAppSelector(selectAgentProfiles);
const activeId = useAppSelector(selectActiveAgentProfileId);
@@ -74,7 +76,7 @@ const ProfilesPanel = () => {
type="button"
variant="primary"
size="sm"
onClick={() => navigate('/settings/profiles/new')}>
onClick={() => navigate('/settings/profiles/new', settingsNavState(location))}>
<LuPlus className="h-4 w-4" />
{t('settings.profiles.new')}
</Button>
@@ -144,7 +146,12 @@ const ProfilesPanel = () => {
type="button"
variant="secondary"
size="sm"
onClick={() => navigate(`/settings/profiles/edit/${profile.id}`)}>
onClick={() =>
navigate(
`/settings/profiles/edit/${profile.id}`,
settingsNavState(location)
)
}>
{t('common.edit')}
</Button>
{!profile.builtIn && (
@@ -0,0 +1,226 @@
import type { ReactNode } from 'react';
import { Navigate, Route, useLocation } from 'react-router-dom';
import WorkflowsTab from '../intelligence/WorkflowsTab';
import SettingsIndexRedirect from './layout/SettingsIndexRedirect';
import AboutPanel from './panels/AboutPanel';
import AccountPanel from './panels/AccountPanel';
import AgentAccessPanel from './panels/AgentAccessPanel';
import AgentActivityPanel from './panels/AgentActivityPanel';
import AgentBoxPanel from './panels/AgentBoxPanel';
import AgentChatPanel from './panels/AgentChatPanel';
import AgentEditorPage from './panels/AgentEditorPage';
import AgentsPanel from './panels/AgentsPanel';
import AppearancePanel from './panels/AppearancePanel';
import ApprovalHistoryPanel from './panels/ApprovalHistoryPanel';
import AutocompleteDebugPanel from './panels/AutocompleteDebugPanel';
import AutocompletePanel from './panels/AutocompletePanel';
import BillingPanel from './panels/BillingPanel';
import CompanionPanel from './panels/CompanionPanel';
import ComposioTriagePanel from './panels/ComposioTriagePanel';
import CronJobsPanel from './panels/CronJobsPanel';
import DesktopAgentPanel from './panels/DesktopAgentPanel';
import DeveloperOptionsPanel from './panels/DeveloperOptionsPanel';
import DevicesPanel from './panels/DevicesPanel';
import DevWorkflowPanel from './panels/DevWorkflowPanel';
import EventLogPanel from './panels/EventLogPanel';
import IntegrationsPanel from './panels/IntegrationsPanel';
import LocalModelDebugPanel from './panels/LocalModelDebugPanel';
import McpServerPanel from './panels/McpServerPanel';
import MeetingSettingsPanel from './panels/MeetingSettingsPanel';
import MemorySyncPanel from './panels/MemorySyncPanel';
import MigrationPanel from './panels/MigrationPanel';
import ModelHealthPanel from './panels/ModelHealthPanel';
import NotificationsTabbedPanel from './panels/NotificationsTabbedPanel';
import PermissionsPanel from './panels/PermissionsPanel';
import PersonalityPanel from './panels/PersonalityPanel';
import PrivacyPanel from './panels/PrivacyPanel';
import ProfileEditorPage from './panels/ProfileEditorPage';
import ProfilesPanel from './panels/ProfilesPanel';
import RecoveryPhrasePanel from './panels/RecoveryPhrasePanel';
import SandboxSettingsPanel from './panels/SandboxSettingsPanel';
import ScreenAwarenessDebugPanel from './panels/ScreenAwarenessDebugPanel';
import ScreenIntelligencePanel from './panels/ScreenIntelligencePanel';
import SecurityPanel from './panels/SecurityPanel';
import TasksPanel from './panels/TasksPanel';
import TeamInvitesPanel from './panels/TeamInvitesPanel';
import TeamManagementPanel from './panels/TeamManagementPanel';
import TeamMembersPanel from './panels/TeamMembersPanel';
import TeamPanel from './panels/TeamPanel';
import ToolPolicyDiagnosticsPanel from './panels/ToolPolicyDiagnosticsPanel';
import ToolsPanel from './panels/ToolsPanel';
import UsagePanel from './panels/UsagePanel';
import VoiceDebugPanel from './panels/VoiceDebugPanel';
import WalletBalancesPanel from './panels/WalletBalancesPanel';
import WebhooksDebugPanel from './panels/WebhooksDebugPanel';
import WorkflowRunnerPanel from './panels/WorkflowRunnerPanel';
/**
* Single vertical-scroll wrapper for a settings panel. The surrounding card
* (bg / border / rounding) is provided by the host — `SettingsLayout`'s content
* pane on iOS, or `SettingsModalLayout`'s right column on desktop — so panels
* sit directly on it. PanelScaffold-based panels are `h-full` and own their own
* internal scroll; legacy panels that overflow scroll here. Either way there's
* exactly one scrollbar.
*/
export const WrappedSettingsPage = ({ children }: { children: ReactNode }) => {
return <div className="h-full min-h-0 overflow-y-auto">{children}</div>;
};
const wrapSettingsPage = (element: ReactNode) => (
<WrappedSettingsPage>{element}</WrappedSettingsPage>
);
/**
* Redirect that stays *within* `/settings/*` while preserving nav state — most
* importantly the desktop modal's `backgroundLocation`, so a legacy-slug hop
* inside the modal keeps its backdrop instead of falling back to the default
* page. Use this for in-settings redirects only; external redirects (`/brain`,
* `/connections`) intentionally exit the modal and keep a plain `<Navigate>`.
*/
const SettingsRedirect = ({ to }: { to: string }) => {
const location = useLocation();
return <Navigate to={to} replace state={location.state} />;
};
/**
* The full settings route table — index, every panel, and every legacy-slug
* redirect. Returned as a fragment of `<Route>` elements (via a function call,
* not a nested component) so it can be embedded directly inside a `<Routes>` in
* both hosts:
*
* - Desktop modal: `<Routes>{settingsRouteElements()}</Routes>`
* - iOS full page: `<Routes><Route element={<SettingsLayout/>}>{settingsRouteElements()}</Route></Routes>`
*
* Retired slugs are kept as redirects so deep links keep working.
*/
export function settingsRouteElements(): ReactNode {
return (
<>
<Route index element={<SettingsIndexRedirect />} />
{/* ── General ─────────────────────────────────────────────── */}
<Route path="account" element={wrapSettingsPage(<AccountPanel />)} />
<Route path="team" element={wrapSettingsPage(<TeamPanel />)} />
<Route path="team/manage/:teamId" element={wrapSettingsPage(<TeamManagementPanel />)} />
<Route path="team/manage/:teamId/members" element={wrapSettingsPage(<TeamMembersPanel />)} />
<Route path="team/manage/:teamId/invites" element={wrapSettingsPage(<TeamInvitesPanel />)} />
<Route path="team/members" element={wrapSettingsPage(<TeamMembersPanel />)} />
<Route path="team/invites" element={wrapSettingsPage(<TeamInvitesPanel />)} />
<Route path="billing" element={wrapSettingsPage(<BillingPanel />)} />
<Route path="privacy" element={wrapSettingsPage(<PrivacyPanel />)} />
<Route path="security" element={wrapSettingsPage(<SecurityPanel />)} />
<Route path="migration" element={wrapSettingsPage(<MigrationPanel />)} />
<Route path="appearance" element={wrapSettingsPage(<AppearancePanel />)} />
<Route path="notifications" element={wrapSettingsPage(<NotificationsTabbedPanel />)} />
{/* Real device-pairing panel (replaces the old "Coming Soon" stub). */}
<Route path="devices" element={wrapSettingsPage(<DevicesPanel />)} />
{/* ── Assistant ───────────────────────────────────────────── */}
{/* LLM / Voice / Embeddings moved to the Connections page. */}
<Route path="llm" element={<Navigate to="/connections?tab=llm" replace />} />
<Route path="embeddings" element={<Navigate to="/connections?tab=embeddings" replace />} />
<Route path="usage" element={wrapSettingsPage(<UsagePanel />)} />
<Route path="voice" element={<Navigate to="/connections?tab=voice" replace />} />
<Route path="personality" element={wrapSettingsPage(<PersonalityPanel />)} />
<Route path="agents" element={wrapSettingsPage(<AgentsPanel />)} />
<Route path="agents/new" element={wrapSettingsPage(<AgentEditorPage />)} />
<Route path="agents/edit/:id" element={wrapSettingsPage(<AgentEditorPage />)} />
{/* Top-level agent profiles (soul, memory, skills, MCP, connectors). */}
<Route path="profiles" element={wrapSettingsPage(<ProfilesPanel />)} />
<Route path="profiles/new" element={wrapSettingsPage(<ProfileEditorPage />)} />
<Route path="profiles/edit/:id" element={wrapSettingsPage(<ProfileEditorPage />)} />
<Route path="agent-access" element={wrapSettingsPage(<AgentAccessPanel />)} />
<Route path="activity-level" element={wrapSettingsPage(<AgentActivityPanel />)} />
<Route path="sandbox-settings" element={wrapSettingsPage(<SandboxSettingsPanel />)} />
<Route path="approval-history" element={wrapSettingsPage(<ApprovalHistoryPanel />)} />
{/* ── Data ────────────────────────────────────────────────── */}
<Route path="memory-sync" element={wrapSettingsPage(<MemorySyncPanel />)} />
<Route path="wallet-balances" element={wrapSettingsPage(<WalletBalancesPanel />)} />
<Route path="recovery-phrase" element={wrapSettingsPage(<RecoveryPhrasePanel />)} />
{/* ── Connections ─────────────────────────────────────────── */}
<Route path="integrations" element={wrapSettingsPage(<IntegrationsPanel />)} />
<Route path="screen-intelligence" element={wrapSettingsPage(<ScreenIntelligencePanel />)} />
<Route path="desktop-agent" element={wrapSettingsPage(<DesktopAgentPanel />)} />
<Route path="tools" element={wrapSettingsPage(<ToolsPanel />)} />
<Route path="companion" element={wrapSettingsPage(<CompanionPanel />)} />
<Route path="meetings" element={wrapSettingsPage(<MeetingSettingsPanel />)} />
<Route path="autocomplete" element={wrapSettingsPage(<AutocompletePanel />)} />
{/* ── System ──────────────────────────────────────────────── */}
<Route path="developer-options" element={wrapSettingsPage(<DeveloperOptionsPanel />)} />
<Route path="about" element={wrapSettingsPage(<AboutPanel />)} />
{/* ── Developer & Diagnostics leaf panels ─────────────────── */}
<Route
path="tool-policy-diagnostics"
element={wrapSettingsPage(<ToolPolicyDiagnosticsPanel />)}
/>
<Route path="agentbox" element={wrapSettingsPage(<AgentBoxPanel />)} />
<Route path="mcp-server" element={wrapSettingsPage(<McpServerPanel />)} />
{/* Search engine settings moved to the Connections page. */}
<Route path="search" element={<Navigate to="/connections?tab=search" replace />} />
<Route path="agent-chat" element={wrapSettingsPage(<AgentChatPanel />)} />
<Route path="cron-jobs" element={wrapSettingsPage(<CronJobsPanel />)} />
<Route path="tasks" element={wrapSettingsPage(<TasksPanel />)} />
<Route path="automations" element={wrapSettingsPage(<WorkflowsTab />)} />
<Route path="dev-workflow" element={wrapSettingsPage(<DevWorkflowPanel />)} />
<Route path="skills-runner" element={wrapSettingsPage(<WorkflowRunnerPanel />)} />
<Route
path="screen-awareness-debug"
element={wrapSettingsPage(<ScreenAwarenessDebugPanel />)}
/>
<Route path="autocomplete-debug" element={wrapSettingsPage(<AutocompleteDebugPanel />)} />
<Route path="voice-debug" element={wrapSettingsPage(<VoiceDebugPanel />)} />
<Route path="local-model-debug" element={wrapSettingsPage(<LocalModelDebugPanel />)} />
<Route path="webhooks-debug" element={wrapSettingsPage(<WebhooksDebugPanel />)} />
<Route path="event-log" element={wrapSettingsPage(<EventLogPanel />)} />
<Route path="model-health" element={wrapSettingsPage(<ModelHealthPanel />)} />
{/* Knowledge & Memory panels moved to the Brain page. */}
<Route path="memory-data" element={<Navigate to="/brain?tab=memory-data" replace />} />
<Route path="memory-debug" element={<Navigate to="/brain?tab=memory-debug" replace />} />
<Route path="analysis-views" element={<Navigate to="/brain?tab=analysis-views" replace />} />
<Route path="intelligence" element={<Navigate to="/brain?tab=intelligence" replace />} />
<Route path="composio-triggers" element={wrapSettingsPage(<ComposioTriagePanel />)} />
<Route path="permissions" element={wrapSettingsPage(<PermissionsPanel />)} />
{/* ── Legacy slugs → redirects (deep-link compatibility) ──── */}
{/* Old hub pages */}
<Route path="ai" element={<Navigate to="/connections?tab=llm" replace />} />
<Route path="agents-settings" element={<SettingsRedirect to="/settings/agents" />} />
<Route path="features" element={<SettingsRedirect to="/settings/screen-intelligence" />} />
<Route path="crypto" element={<SettingsRedirect to="/settings/wallet-balances" />} />
<Route path="notifications-hub" element={<SettingsRedirect to="/settings/notifications" />} />
{/* Composio (API key + routing) moved to Connections → API keys. */}
<Route path="composio" element={<Navigate to="/connections?tab=composio-key" replace />} />
{/* Merged Usage & Limits page */}
<Route path="heartbeat" element={<SettingsRedirect to="/settings/usage#background" />} />
<Route path="ledger-usage" element={<SettingsRedirect to="/settings/usage#background" />} />
<Route path="cost-dashboard" element={<SettingsRedirect to="/settings/usage" />} />
{/* Autonomy rate-limit lives inside Agent access now */}
<Route path="autonomy" element={<SettingsRedirect to="/settings/agent-access" />} />
{/* Merged Personality & Face page */}
<Route path="mascot" element={<SettingsRedirect to="/settings/personality#face" />} />
<Route path="persona" element={<SettingsRedirect to="/settings/personality" />} />
{/* Merged Integrations page */}
<Route path="task-sources" element={<SettingsRedirect to="/settings/integrations" />} />
<Route
path="composio-routing"
element={<Navigate to="/connections?tab=composio-key" replace />}
/>
<Route
path="webhooks-triggers"
element={<SettingsRedirect to="/settings/integrations#webhooks" />}
/>
{/* Notification routing tab */}
<Route
path="notification-routing"
element={<SettingsRedirect to="/settings/notifications#routing" />}
/>
{/* Fallback */}
<Route path="*" element={<SettingsRedirect to="/settings" />} />
</>
);
}
@@ -6,7 +6,7 @@
* Intelligence setup modal.
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useT } from '../../lib/i18n/I18nContext';
import { useCoreState } from '../../providers/CoreStateProvider';
@@ -14,6 +14,7 @@ import {
openhumanAutocompleteSetStyle,
openhumanAutocompleteStart,
} from '../../utils/tauriCommands/autocomplete';
import { settingsNavState } from '../settings/modal/settingsOverlay';
import {
SetupNotice,
SetupSettingRow,
@@ -29,6 +30,7 @@ interface Props {
export default function AutocompleteSetupModal({ onClose }: Props) {
const navigate = useNavigate();
const location = useLocation();
const { t } = useT();
const { snapshot, refresh } = useCoreState();
const status = snapshot.runtime.autocomplete;
@@ -58,7 +60,7 @@ export default function AutocompleteSetupModal({ onClose }: Props) {
const handleGoToSettings = () => {
onClose();
navigate('/settings/autocomplete');
navigate('/settings/autocomplete', settingsNavState(location));
};
return (
@@ -6,11 +6,12 @@
* skill setup flows (Gmail, etc.).
*/
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useScreenIntelligenceState } from '../../features/screen-intelligence/useScreenIntelligenceState';
import { useT } from '../../lib/i18n/I18nContext';
import { openhumanUpdateScreenIntelligenceSettings } from '../../utils/tauriCommands';
import { settingsNavState } from '../settings/modal/settingsOverlay';
import { CheckIcon } from '../ui';
import {
SetupNotice,
@@ -90,6 +91,7 @@ const PermissionRow = ({
export default function ScreenIntelligenceSetupModal({ onClose, initialStep }: Props) {
const navigate = useNavigate();
const location = useLocation();
const { t } = useT();
const {
status,
@@ -151,7 +153,7 @@ export default function ScreenIntelligenceSetupModal({ onClose, initialStep }: P
const handleGoToSettings = () => {
onClose();
navigate('/settings/screen-intelligence');
navigate('/settings/screen-intelligence', settingsNavState(location));
};
if (status?.platform_supported === false) {
@@ -5,7 +5,7 @@
* settings. Otherwise, starts the voice server and shows success.
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import type { VoiceSkillStatus } from '../../features/voice/useVoiceSkillStatus';
import { useT } from '../../lib/i18n/I18nContext';
@@ -13,6 +13,7 @@ import {
openhumanUpdateVoiceServerSettings,
openhumanVoiceServerStart,
} from '../../utils/tauriCommands/voice';
import { settingsNavState } from '../settings/modal/settingsOverlay';
import { CheckIcon, WarningIcon } from '../ui';
import {
SetupNotice,
@@ -30,6 +31,7 @@ interface Props {
export default function VoiceSetupModal({ onClose, skillStatus }: Props) {
const navigate = useNavigate();
const location = useLocation();
const { t } = useT();
const { sttModelMissing, serverStatus } = skillStatus;
@@ -57,12 +59,12 @@ export default function VoiceSetupModal({ onClose, skillStatus }: Props) {
onClose();
// STT model install lives on the Voice settings panel (PR 2). The
// legacy `/settings/local-model` route handled Ollama assets only.
navigate('/settings/voice');
navigate('/settings/voice', settingsNavState(location));
};
const handleGoToSettings = () => {
onClose();
navigate('/settings/voice');
navigate('/settings/voice', settingsNavState(location));
};
return (
@@ -13,7 +13,11 @@ vi.mock('../../../features/screen-intelligence/useScreenIntelligenceState', () =
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<Record<string, unknown>>('react-router-dom');
return { ...actual, useNavigate: vi.fn(() => vi.fn()) };
return {
...actual,
useNavigate: vi.fn(() => vi.fn()),
useLocation: () => ({ pathname: '/connections', search: '', hash: '', state: null, key: 'test' }),
};
});
vi.mock('../../../utils/tauriCommands', async () => {
+2 -1
View File
@@ -13,6 +13,7 @@ import ChatNewWindowHero from '../components/chat/ChatNewWindowHero';
import ComposerTokenStats from '../components/chat/ComposerTokenStats';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
import { settingsNavState } from '../components/settings/modal/settingsOverlay';
import UpsellBanner from '../components/upsell/UpsellBanner';
import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDismissState';
import MicComposer from '../features/human/MicComposer';
@@ -2431,7 +2432,7 @@ const Conversations = ({
// STT/TTS provider settings live on the Voice panel
// since PR 2; the legacy local-model route was for
// back when speech assets were lumped with Ollama.
navigate('/settings/voice');
navigate('/settings/voice', settingsNavState(location));
}}
className="text-xs text-primary-500 hover:text-primary-600 font-medium transition-colors">
{t('chat.setup')}
+4 -2
View File
@@ -1,12 +1,13 @@
import createDebug from 'debug';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import EmptyStateCard from '../components/EmptyStateCard';
import PillTabBar from '../components/PillTabBar';
import RewardsCommunityTab from '../components/rewards/RewardsCommunityTab';
import RewardsRedeemTab from '../components/rewards/RewardsRedeemTab';
import RewardsReferralsTab from '../components/rewards/RewardsReferralsTab';
import { settingsNavState } from '../components/settings/modal/settingsOverlay';
import { useT } from '../lib/i18n/I18nContext';
import { useCoreState } from '../providers/CoreStateProvider';
import { rewardsApi } from '../services/api/rewardsApi';
@@ -30,6 +31,7 @@ function errorMessage(err: unknown): string {
const Rewards = () => {
const { t } = useT();
const navigate = useNavigate();
const location = useLocation();
const { snapshot: coreSnapshot } = useCoreState();
const isLocalSession = isLocalSessionToken(coreSnapshot.sessionToken);
const [selectedTab, setSelectedTab] = useState<RewardsTab>('rewards');
@@ -124,7 +126,7 @@ const Rewards = () => {
title={t('rewards.title')}
description={t('rewards.localUnavailable')}
actionLabel={t('rewards.localUnavailableCta')}
onAction={() => navigate('/settings/account')}
onAction={() => navigate('/settings/account', settingsNavState(location))}
/>
</div>
</div>
+14 -228
View File
@@ -1,241 +1,27 @@
import type { ReactNode } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Route, Routes } from 'react-router-dom';
import WorkflowsTab from '../components/intelligence/WorkflowsTab';
import SettingsIndexRedirect from '../components/settings/layout/SettingsIndexRedirect';
import SettingsLayout from '../components/settings/layout/SettingsLayout';
import AboutPanel from '../components/settings/panels/AboutPanel';
import AccountPanel from '../components/settings/panels/AccountPanel';
import AgentAccessPanel from '../components/settings/panels/AgentAccessPanel';
import AgentActivityPanel from '../components/settings/panels/AgentActivityPanel';
import AgentBoxPanel from '../components/settings/panels/AgentBoxPanel';
import AgentChatPanel from '../components/settings/panels/AgentChatPanel';
import AgentEditorPage from '../components/settings/panels/AgentEditorPage';
import AgentsPanel from '../components/settings/panels/AgentsPanel';
import AppearancePanel from '../components/settings/panels/AppearancePanel';
import ApprovalHistoryPanel from '../components/settings/panels/ApprovalHistoryPanel';
import AutocompleteDebugPanel from '../components/settings/panels/AutocompleteDebugPanel';
import AutocompletePanel from '../components/settings/panels/AutocompletePanel';
import BillingPanel from '../components/settings/panels/BillingPanel';
import CompanionPanel from '../components/settings/panels/CompanionPanel';
import ComposioTriagePanel from '../components/settings/panels/ComposioTriagePanel';
import CronJobsPanel from '../components/settings/panels/CronJobsPanel';
import DesktopAgentPanel from '../components/settings/panels/DesktopAgentPanel';
import DeveloperOptionsPanel from '../components/settings/panels/DeveloperOptionsPanel';
import DevicesPanel from '../components/settings/panels/DevicesPanel';
import DevWorkflowPanel from '../components/settings/panels/DevWorkflowPanel';
import EventLogPanel from '../components/settings/panels/EventLogPanel';
import IntegrationsPanel from '../components/settings/panels/IntegrationsPanel';
import LocalModelDebugPanel from '../components/settings/panels/LocalModelDebugPanel';
import McpServerPanel from '../components/settings/panels/McpServerPanel';
import MeetingSettingsPanel from '../components/settings/panels/MeetingSettingsPanel';
import MemorySyncPanel from '../components/settings/panels/MemorySyncPanel';
import MigrationPanel from '../components/settings/panels/MigrationPanel';
import ModelHealthPanel from '../components/settings/panels/ModelHealthPanel';
import NotificationsTabbedPanel from '../components/settings/panels/NotificationsTabbedPanel';
import PermissionsPanel from '../components/settings/panels/PermissionsPanel';
import PersonalityPanel from '../components/settings/panels/PersonalityPanel';
import PrivacyPanel from '../components/settings/panels/PrivacyPanel';
import ProfileEditorPage from '../components/settings/panels/ProfileEditorPage';
import ProfilesPanel from '../components/settings/panels/ProfilesPanel';
import RecoveryPhrasePanel from '../components/settings/panels/RecoveryPhrasePanel';
import SandboxSettingsPanel from '../components/settings/panels/SandboxSettingsPanel';
import ScreenAwarenessDebugPanel from '../components/settings/panels/ScreenAwarenessDebugPanel';
import ScreenIntelligencePanel from '../components/settings/panels/ScreenIntelligencePanel';
import SecurityPanel from '../components/settings/panels/SecurityPanel';
import TasksPanel from '../components/settings/panels/TasksPanel';
import TeamInvitesPanel from '../components/settings/panels/TeamInvitesPanel';
import TeamManagementPanel from '../components/settings/panels/TeamManagementPanel';
import TeamMembersPanel from '../components/settings/panels/TeamMembersPanel';
import TeamPanel from '../components/settings/panels/TeamPanel';
import ToolPolicyDiagnosticsPanel from '../components/settings/panels/ToolPolicyDiagnosticsPanel';
import ToolsPanel from '../components/settings/panels/ToolsPanel';
import UsagePanel from '../components/settings/panels/UsagePanel';
import VoiceDebugPanel from '../components/settings/panels/VoiceDebugPanel';
import WalletBalancesPanel from '../components/settings/panels/WalletBalancesPanel';
import WebhooksDebugPanel from '../components/settings/panels/WebhooksDebugPanel';
import WorkflowRunnerPanel from '../components/settings/panels/WorkflowRunnerPanel';
const WrappedSettingsPage = ({ children }: { children: ReactNode }) => {
// The surrounding two-pane card (bg / border / rounding) is provided by
// SettingsLayout's content pane, so panels sit directly on it. This wrapper
// fills the bounded Outlet area and is the page's single vertical scroll
// region: PanelScaffold-based panels are `h-full` and own their own internal
// scroll (so this never scrolls for them), while legacy panels that overflow
// scroll here. Either way there's exactly one scrollbar.
return <div className="h-full min-h-0 overflow-y-auto">{children}</div>;
};
import { settingsRouteElements } from '../components/settings/settingsRouteElements';
/**
* Settings routes, hosted inside the two-pane SettingsLayout (persistent
* sidebar on md+, drill-down on narrow viewports). Retired slugs are kept as
* redirects so deep links keep working.
* Full-page Settings host. Used on iOS (and any non-desktop target) where
* Settings is a routed screen rather than the desktop modal overlay. Wraps the
* shared {@link settingsRouteElements} route table in the two-pane
* {@link SettingsLayout} (persistent sidebar on md+, drill-down on narrow
* viewports). Retired slugs are kept as redirects inside the shared table so
* deep links keep working.
*
* Desktop no longer mounts this — it renders the same route table inside
* `SettingsModal` (see components/settings/modal/). The route elements are
* shared so both hosts stay in lockstep.
*/
const Settings = () => {
const wrapSettingsPage = (element: ReactNode) => (
<WrappedSettingsPage>{element}</WrappedSettingsPage>
);
return (
// h-full chains the AppShell page-scroller height down to SettingsLayout so
// its panes can bound to the viewport (minus the bottom bar, via the
// scroller's pb-16) and scroll internally — instead of the whole page
// growing and scrolling as one.
// its panes can bound to the viewport and scroll internally.
<div className="h-full">
<Routes>
<Route element={<SettingsLayout />}>
<Route index element={<SettingsIndexRedirect />} />
{/* ── General ─────────────────────────────────────────────── */}
<Route path="account" element={wrapSettingsPage(<AccountPanel />)} />
<Route path="team" element={wrapSettingsPage(<TeamPanel />)} />
<Route path="team/manage/:teamId" element={wrapSettingsPage(<TeamManagementPanel />)} />
<Route
path="team/manage/:teamId/members"
element={wrapSettingsPage(<TeamMembersPanel />)}
/>
<Route
path="team/manage/:teamId/invites"
element={wrapSettingsPage(<TeamInvitesPanel />)}
/>
<Route path="team/members" element={wrapSettingsPage(<TeamMembersPanel />)} />
<Route path="team/invites" element={wrapSettingsPage(<TeamInvitesPanel />)} />
<Route path="billing" element={wrapSettingsPage(<BillingPanel />)} />
<Route path="privacy" element={wrapSettingsPage(<PrivacyPanel />)} />
<Route path="security" element={wrapSettingsPage(<SecurityPanel />)} />
<Route path="migration" element={wrapSettingsPage(<MigrationPanel />)} />
<Route path="appearance" element={wrapSettingsPage(<AppearancePanel />)} />
<Route path="notifications" element={wrapSettingsPage(<NotificationsTabbedPanel />)} />
{/* Real device-pairing panel (replaces the old "Coming Soon" stub). */}
<Route path="devices" element={wrapSettingsPage(<DevicesPanel />)} />
{/* ── Assistant ───────────────────────────────────────────── */}
{/* LLM / Voice / Embeddings moved to the Connections page. */}
<Route path="llm" element={<Navigate to="/connections?tab=llm" replace />} />
<Route
path="embeddings"
element={<Navigate to="/connections?tab=embeddings" replace />}
/>
<Route path="usage" element={wrapSettingsPage(<UsagePanel />)} />
<Route path="voice" element={<Navigate to="/connections?tab=voice" replace />} />
<Route path="personality" element={wrapSettingsPage(<PersonalityPanel />)} />
<Route path="agents" element={wrapSettingsPage(<AgentsPanel />)} />
<Route path="agents/new" element={wrapSettingsPage(<AgentEditorPage />)} />
<Route path="agents/edit/:id" element={wrapSettingsPage(<AgentEditorPage />)} />
{/* Top-level agent profiles (soul, memory, skills, MCP, connectors). */}
<Route path="profiles" element={wrapSettingsPage(<ProfilesPanel />)} />
<Route path="profiles/new" element={wrapSettingsPage(<ProfileEditorPage />)} />
<Route path="profiles/edit/:id" element={wrapSettingsPage(<ProfileEditorPage />)} />
<Route path="agent-access" element={wrapSettingsPage(<AgentAccessPanel />)} />
<Route path="activity-level" element={wrapSettingsPage(<AgentActivityPanel />)} />
<Route path="sandbox-settings" element={wrapSettingsPage(<SandboxSettingsPanel />)} />
<Route path="approval-history" element={wrapSettingsPage(<ApprovalHistoryPanel />)} />
{/* ── Data ────────────────────────────────────────────────── */}
<Route path="memory-sync" element={wrapSettingsPage(<MemorySyncPanel />)} />
<Route path="wallet-balances" element={wrapSettingsPage(<WalletBalancesPanel />)} />
<Route path="recovery-phrase" element={wrapSettingsPage(<RecoveryPhrasePanel />)} />
{/* ── Connections ─────────────────────────────────────────── */}
<Route path="integrations" element={wrapSettingsPage(<IntegrationsPanel />)} />
<Route
path="screen-intelligence"
element={wrapSettingsPage(<ScreenIntelligencePanel />)}
/>
<Route path="desktop-agent" element={wrapSettingsPage(<DesktopAgentPanel />)} />
<Route path="tools" element={wrapSettingsPage(<ToolsPanel />)} />
<Route path="companion" element={wrapSettingsPage(<CompanionPanel />)} />
<Route path="meetings" element={wrapSettingsPage(<MeetingSettingsPanel />)} />
<Route path="autocomplete" element={wrapSettingsPage(<AutocompletePanel />)} />
{/* ── System ──────────────────────────────────────────────── */}
<Route path="developer-options" element={wrapSettingsPage(<DeveloperOptionsPanel />)} />
<Route path="about" element={wrapSettingsPage(<AboutPanel />)} />
{/* ── Developer & Diagnostics leaf panels ─────────────────── */}
<Route
path="tool-policy-diagnostics"
element={wrapSettingsPage(<ToolPolicyDiagnosticsPanel />)}
/>
<Route path="agentbox" element={wrapSettingsPage(<AgentBoxPanel />)} />
<Route path="mcp-server" element={wrapSettingsPage(<McpServerPanel />)} />
{/* Search engine settings moved to the Connections page. */}
<Route path="search" element={<Navigate to="/connections?tab=search" replace />} />
<Route path="agent-chat" element={wrapSettingsPage(<AgentChatPanel />)} />
<Route path="cron-jobs" element={wrapSettingsPage(<CronJobsPanel />)} />
<Route path="tasks" element={wrapSettingsPage(<TasksPanel />)} />
<Route path="automations" element={wrapSettingsPage(<WorkflowsTab />)} />
<Route path="dev-workflow" element={wrapSettingsPage(<DevWorkflowPanel />)} />
<Route path="skills-runner" element={wrapSettingsPage(<WorkflowRunnerPanel />)} />
<Route
path="screen-awareness-debug"
element={wrapSettingsPage(<ScreenAwarenessDebugPanel />)}
/>
<Route path="autocomplete-debug" element={wrapSettingsPage(<AutocompleteDebugPanel />)} />
<Route path="voice-debug" element={wrapSettingsPage(<VoiceDebugPanel />)} />
<Route path="local-model-debug" element={wrapSettingsPage(<LocalModelDebugPanel />)} />
<Route path="webhooks-debug" element={wrapSettingsPage(<WebhooksDebugPanel />)} />
<Route path="event-log" element={wrapSettingsPage(<EventLogPanel />)} />
<Route path="model-health" element={wrapSettingsPage(<ModelHealthPanel />)} />
{/* Knowledge & Memory panels moved to the Brain page. */}
<Route path="memory-data" element={<Navigate to="/brain?tab=memory-data" replace />} />
<Route path="memory-debug" element={<Navigate to="/brain?tab=memory-debug" replace />} />
<Route
path="analysis-views"
element={<Navigate to="/brain?tab=analysis-views" replace />}
/>
<Route path="intelligence" element={<Navigate to="/brain?tab=intelligence" replace />} />
<Route path="composio-triggers" element={wrapSettingsPage(<ComposioTriagePanel />)} />
<Route path="permissions" element={wrapSettingsPage(<PermissionsPanel />)} />
{/* ── Legacy slugs → redirects (deep-link compatibility) ──── */}
{/* Old hub pages */}
<Route path="ai" element={<Navigate to="/connections?tab=llm" replace />} />
<Route path="agents-settings" element={<Navigate to="/settings/agents" replace />} />
<Route
path="features"
element={<Navigate to="/settings/screen-intelligence" replace />}
/>
<Route path="crypto" element={<Navigate to="/settings/wallet-balances" replace />} />
<Route
path="notifications-hub"
element={<Navigate to="/settings/notifications" replace />}
/>
{/* Composio (API key + routing) moved to Connections → API keys. */}
<Route
path="composio"
element={<Navigate to="/connections?tab=composio-key" replace />}
/>
{/* Merged Usage & Limits page */}
<Route path="heartbeat" element={<Navigate to="/settings/usage#background" replace />} />
<Route
path="ledger-usage"
element={<Navigate to="/settings/usage#background" replace />}
/>
<Route path="cost-dashboard" element={<Navigate to="/settings/usage" replace />} />
{/* Autonomy rate-limit lives inside Agent access now */}
<Route path="autonomy" element={<Navigate to="/settings/agent-access" replace />} />
{/* Merged Personality & Face page */}
<Route path="mascot" element={<Navigate to="/settings/personality#face" replace />} />
<Route path="persona" element={<Navigate to="/settings/personality" replace />} />
{/* Merged Integrations page */}
<Route path="task-sources" element={<Navigate to="/settings/integrations" replace />} />
<Route
path="composio-routing"
element={<Navigate to="/connections?tab=composio-key" replace />}
/>
<Route
path="webhooks-triggers"
element={<Navigate to="/settings/integrations#webhooks" replace />}
/>
{/* Notification routing tab */}
<Route
path="notification-routing"
element={<Navigate to="/settings/notifications#routing" replace />}
/>
{/* Fallback */}
<Route path="*" element={<Navigate to="/settings" replace />} />
</Route>
<Route element={<SettingsLayout />}>{settingsRouteElements()}</Route>
</Routes>
</div>
);
@@ -28,7 +28,11 @@ vi.mock('../../../utils/tauriCommands', () => ({
const navigateSpy = vi.hoisted(() => vi.fn());
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return { ...actual, useNavigate: () => navigateSpy };
return {
...actual,
useNavigate: () => navigateSpy,
useLocation: () => ({ pathname: '/chat', search: '', hash: '', state: null, key: 'test' }),
};
});
function card(partial: Partial<TaskBoardCard>): TaskBoardCard {
@@ -203,7 +207,9 @@ describe('TaskKanbanBoard approval surface', () => {
// "Manage sources" jumps to the merged Integrations settings page
// (task-sources was folded into /settings/integrations).
fireEvent.click(screen.getByText('conversations.taskKanban.sources.manage'));
expect(navigateSpy).toHaveBeenCalledWith('/settings/integrations');
expect(navigateSpy).toHaveBeenCalledWith('/settings/integrations', {
state: { backgroundLocation: expect.objectContaining({ pathname: '/chat' }) },
});
});
it('shows a "View work" button on a card with a session thread and calls onViewSession', () => {
@@ -13,8 +13,9 @@ import {
LuWrench,
LuX,
} from 'react-icons/lu';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { settingsNavState } from '../../../components/settings/modal/settingsOverlay';
import { useT } from '../../../lib/i18n/I18nContext';
import type { TaskBoard, TaskBoardCard, TaskBoardCardStatus } from '../../../types/turnState';
import {
@@ -592,6 +593,7 @@ function formatSyncNotice(outcomes: FetchOutcome[], t: (key: string) => string):
function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact: boolean }) {
const { t } = useT();
const navigate = useNavigate();
const location = useLocation();
const [loading, setLoading] = useState(true);
const [sources, setSources] = useState<TaskSource[]>([]);
const [status, setStatus] = useState<TaskSourcesStatus | null>(null);
@@ -704,7 +706,7 @@ function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact:
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => navigate('/settings/integrations')}
onClick={() => navigate('/settings/integrations', settingsNavState(location))}
className="text-[11px] font-medium text-ocean-600 hover:text-ocean-700 dark:text-ocean-300 dark:hover:text-ocean-200">
{t('conversations.taskKanban.sources.manage')}
</button>
+4 -1
View File
@@ -24,7 +24,10 @@ const ROUTES: RouteEntry[] = [
{ route: '/activity', expectedHash: '/settings/notifications' }, // back-compat redirect
{ route: '/intelligence', expectedHash: '/settings/notifications' }, // back-compat redirect
{ route: '/rewards' },
{ route: '/settings' },
// Desktop Settings is a modal overlay (the backgroundLocation pattern): the
// /settings index redirects to the first panel and the modal renders on top
// of the /chat fallback backdrop.
{ route: '/settings', expectedHash: '/settings/account' },
];
test.describe('Navigation', () => {
@@ -54,7 +54,10 @@ test.describe('Top-level functional flows', () => {
const card = page.getByTestId(`workflow-card-${id}`);
await card.getByTitle('More actions').click();
await page.getByTestId(`workflow-uninstall-${id}`).click();
await expect(page.getByRole('dialog')).toBeVisible();
// Workflows/automations now render inside the Settings modal (also
// role="dialog"), so target the uninstall confirm control specifically
// rather than a generic getByRole('dialog') that would match both.
await expect(page.getByTestId('uninstall-skill-confirm')).toBeVisible();
await page.getByTestId('uninstall-skill-confirm').click();
await expect(page.getByText(name)).toHaveCount(0, { timeout: 15_000 });
});