mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(settings): present desktop Settings as a full-app modal overlay (#4002)
This commit is contained in:
+12
-1
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user