From 3a2b38d0c3d0200901dacd10bb69faba16afe3fc Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:46:48 +0530 Subject: [PATCH] feat(settings): present desktop Settings as a full-app modal overlay (#4002) --- app/src/App.tsx | 13 +- app/src/AppRoutes.tsx | 29 ++- .../IntelligenceSubconsciousTab.tsx | 6 +- .../intelligence/IntelligenceTasksTab.tsx | 6 +- .../IntelligenceSubconsciousTab.test.tsx | 11 +- .../__tests__/IntelligenceTasksTab.test.tsx | 14 +- .../layout/shell/CollapsedNavRail.test.tsx | 6 +- .../layout/shell/CollapsedNavRail.tsx | 3 +- .../layout/shell/SidebarHeader.test.tsx | 10 +- .../components/layout/shell/SidebarHeader.tsx | 8 +- .../settings/hooks/useSettingsNavigation.ts | 25 +- .../settings/layout/SettingsIndexRedirect.tsx | 7 +- .../settings/modal/SettingsModal.tsx | 22 ++ .../modal/SettingsModalFrame.test.tsx | 62 +++++ .../settings/modal/SettingsModalFrame.tsx | 79 ++++++ .../settings/modal/SettingsModalLayout.tsx | 48 ++++ .../settings/modal/settingsOverlay.test.ts | 81 ++++++ .../settings/modal/settingsOverlay.ts | 79 ++++++ .../settings/modal/useCloseSettings.test.tsx | 44 ++++ .../settings/modal/useCloseSettings.ts | 29 +++ .../settings/panels/AgentEditorPage.test.tsx | 2 +- .../settings/panels/AgentEditorPage.tsx | 9 +- .../settings/panels/AgentsPanel.test.tsx | 13 +- .../settings/panels/AgentsPanel.tsx | 8 +- .../panels/ProfileEditorPage.test.tsx | 2 +- .../settings/panels/ProfileEditorPage.tsx | 9 +- .../settings/panels/ProfilesPanel.test.tsx | 8 +- .../settings/panels/ProfilesPanel.tsx | 13 +- .../settings/settingsRouteElements.tsx | 226 ++++++++++++++++ .../skills/AutocompleteSetupModal.tsx | 6 +- .../skills/ScreenIntelligenceSetupModal.tsx | 6 +- app/src/components/skills/VoiceSetupModal.tsx | 8 +- .../ScreenIntelligenceSetupModal.test.tsx | 6 +- app/src/pages/Conversations.tsx | 3 +- app/src/pages/Rewards.tsx | 6 +- app/src/pages/Settings.tsx | 242 +----------------- .../components/TaskKanbanBoard.test.tsx | 10 +- .../components/TaskKanbanBoard.tsx | 6 +- app/test/playwright/specs/navigation.spec.ts | 5 +- .../specs/top-level-functional-flows.spec.ts | 5 +- 40 files changed, 871 insertions(+), 304 deletions(-) create mode 100644 app/src/components/settings/modal/SettingsModal.tsx create mode 100644 app/src/components/settings/modal/SettingsModalFrame.test.tsx create mode 100644 app/src/components/settings/modal/SettingsModalFrame.tsx create mode 100644 app/src/components/settings/modal/SettingsModalLayout.tsx create mode 100644 app/src/components/settings/modal/settingsOverlay.test.ts create mode 100644 app/src/components/settings/modal/settingsOverlay.ts create mode 100644 app/src/components/settings/modal/useCloseSettings.test.tsx create mode 100644 app/src/components/settings/modal/useCloseSettings.ts create mode 100644 app/src/components/settings/settingsRouteElements.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index 105ed2cf8..6f7d85d70 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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 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 = (
- + {activeProviderAccount && !accountsOverlayOpen && (
}>{content} )}
+ {/* Desktop Settings modal — mounted over whatever page is rendered + beneath when the URL is a settings path. */} + {settingsOpen && !chromeless && } {/* Hidden Remotion-driven producer for the Meet camera. Mounts a 640×480 JPEG frame stream to the Rust frame bus while a meet diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index 49648e851..db11a97c8 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -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 ( - + {/* Public routes - redirect to /home if logged in */} { } /> - - - - } - /> + {/* 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. */} } /> diff --git a/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx b/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx index f3404f553..1248408a7 100644 --- a/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx +++ b/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx @@ -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({
diff --git a/app/src/components/intelligence/IntelligenceTasksTab.tsx b/app/src/components/intelligence/IntelligenceTasksTab.tsx index 5321e259f..af730c26f 100644 --- a/app/src/components/intelligence/IntelligenceTasksTab.tsx +++ b/app/src/components/intelligence/IntelligenceTasksTab.tsx @@ -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({ diff --git a/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx b/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx index b53b672a7..c51dbaad4 100644 --- a/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx +++ b/app/src/components/intelligence/__tests__/IntelligenceSubconsciousTab.test.tsx @@ -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 { return { diff --git a/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx b/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx index 9a34d32c8..a0f6347f1 100644 --- a/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx +++ b/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx @@ -67,7 +67,17 @@ vi.mock('../../../store/hooks', () => ({ vi.mock('react-router-dom', async () => { const actual = await vi.importActual('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 () => { diff --git a/app/src/components/layout/shell/CollapsedNavRail.test.tsx b/app/src/components/layout/shell/CollapsedNavRail.test.tsx index cfea2e01e..9b0508992 100644 --- a/app/src/components/layout/shell/CollapsedNavRail.test.tsx +++ b/app/src/components/layout/shell/CollapsedNavRail.test.tsx @@ -37,7 +37,11 @@ describe('CollapsedNavRail', () => { it('wallet button navigates to /settings/wallet-balances', () => { renderWithProviders(, { 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', () => { diff --git a/app/src/components/layout/shell/CollapsedNavRail.tsx b/app/src/components/layout/shell/CollapsedNavRail.tsx index a2019f61f..2282d0439 100644 --- a/app/src/components/layout/shell/CollapsedNavRail.tsx +++ b/app/src/components/layout/shell/CollapsedNavRail.tsx @@ -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() { +
+ {children} +
+ + , + portalTarget + ); +} diff --git a/app/src/components/settings/modal/SettingsModalLayout.tsx b/app/src/components/settings/modal/SettingsModalLayout.tsx new file mode 100644 index 000000000..5778b84e1 --- /dev/null +++ b/app/src/components/settings/modal/SettingsModalLayout.tsx @@ -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 `` renders the matched panel into the right column. + */ +export default function SettingsModalLayout() { + return ( + +
+
+ +
+ {/* Right column: the sub-nav pill row (Account → Team/Privacy/… ) pinned + above the routed panel, mirroring the iOS full-page SettingsLayout. */} +
+
+ +
+
+ + }> + {settingsRouteElements()} + + +
+
+
+
+ ); +} diff --git a/app/src/components/settings/modal/settingsOverlay.test.ts b/app/src/components/settings/modal/settingsOverlay.test.ts new file mode 100644 index 000000000..d8e4ca0dc --- /dev/null +++ b/app/src/components/settings/modal/settingsOverlay.test.ts @@ -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, + }); + }); +}); diff --git a/app/src/components/settings/modal/settingsOverlay.ts b/app/src/components/settings/modal/settingsOverlay.ts new file mode 100644 index 000000000..1ccc45281 --- /dev/null +++ b/app/src/components/settings/modal/settingsOverlay.ts @@ -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 }; +} diff --git a/app/src/components/settings/modal/useCloseSettings.test.tsx b/app/src/components/settings/modal/useCloseSettings.test.tsx new file mode 100644 index 000000000..a36a273d1 --- /dev/null +++ b/app/src/components/settings/modal/useCloseSettings.test.tsx @@ -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 ( + + ); +} + +const currentPath = () => screen.getByTestId('probe').getAttribute('data-path'); + +describe('useCloseSettings', () => { + it('navigates to the stored backgroundLocation when present', () => { + render( + + + + ); + 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( + + + + ); + fireEvent.click(screen.getByTestId('probe')); + expect(currentPath()).toBe('/chat'); + }); +}); diff --git a/app/src/components/settings/modal/useCloseSettings.ts b/app/src/components/settings/modal/useCloseSettings.ts new file mode 100644 index 000000000..5a233b7b5 --- /dev/null +++ b/app/src/components/settings/modal/useCloseSettings.ts @@ -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]); +} diff --git a/app/src/components/settings/panels/AgentEditorPage.test.tsx b/app/src/components/settings/panels/AgentEditorPage.test.tsx index 5ef1d5edb..d07328acd 100644 --- a/app/src/components/settings/panels/AgentEditorPage.test.tsx +++ b/app/src/components/settings/panels/AgentEditorPage.test.tsx @@ -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 () => { diff --git a/app/src/components/settings/panels/AgentEditorPage.tsx b/app/src/components/settings/panels/AgentEditorPage.tsx index 72292ca47..9a7140c9d 100644 --- a/app/src/components/settings/panels/AgentEditorPage.tsx +++ b/app/src/components/settings/panels/AgentEditorPage.tsx @@ -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); diff --git a/app/src/components/settings/panels/AgentsPanel.test.tsx b/app/src/components/settings/panels/AgentsPanel.test.tsx index d738ed113..0885a89c1 100644 --- a/app/src/components/settings/panels/AgentsPanel.test.tsx +++ b/app/src/components/settings/panels/AgentsPanel.test.tsx @@ -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 () => { diff --git a/app/src/components/settings/panels/AgentsPanel.tsx b/app/src/components/settings/panels/AgentsPanel.tsx index ac6e6aaf4..78bc97a54 100644 --- a/app/src/components/settings/panels/AgentsPanel.tsx +++ b/app/src/components/settings/panels/AgentsPanel.tsx @@ -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([]); 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')}> {t('settings.agents.newAgent')} @@ -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)} /> ))} diff --git a/app/src/components/settings/panels/ProfileEditorPage.test.tsx b/app/src/components/settings/panels/ProfileEditorPage.test.tsx index b23605c75..e43b897e7 100644 --- a/app/src/components/settings/panels/ProfileEditorPage.test.tsx +++ b/app/src/components/settings/panels/ProfileEditorPage.test.tsx @@ -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', () => { diff --git a/app/src/components/settings/panels/ProfileEditorPage.tsx b/app/src/components/settings/panels/ProfileEditorPage.tsx index 761e7b239..0722acd4b 100644 --- a/app/src/components/settings/panels/ProfileEditorPage.tsx +++ b/app/src/components/settings/panels/ProfileEditorPage.tsx @@ -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( diff --git a/app/src/components/settings/panels/ProfilesPanel.test.tsx b/app/src/components/settings/panels/ProfilesPanel.test.tsx index 00b7b4802..824b4744d 100644 --- a/app/src/components/settings/panels/ProfilesPanel.test.tsx +++ b/app/src/components/settings/panels/ProfilesPanel.test.tsx @@ -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 () => { diff --git a/app/src/components/settings/panels/ProfilesPanel.tsx b/app/src/components/settings/panels/ProfilesPanel.tsx index 7e469acee..c3110af30 100644 --- a/app/src/components/settings/panels/ProfilesPanel.tsx +++ b/app/src/components/settings/panels/ProfilesPanel.tsx @@ -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))}> {t('settings.profiles.new')} @@ -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')} {!profile.builtIn && ( diff --git a/app/src/components/settings/settingsRouteElements.tsx b/app/src/components/settings/settingsRouteElements.tsx new file mode 100644 index 000000000..d489b67c9 --- /dev/null +++ b/app/src/components/settings/settingsRouteElements.tsx @@ -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
{children}
; +}; + +const wrapSettingsPage = (element: ReactNode) => ( + {element} +); + +/** + * 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 ``. + */ +const SettingsRedirect = ({ to }: { to: string }) => { + const location = useLocation(); + return ; +}; + +/** + * The full settings route table — index, every panel, and every legacy-slug + * redirect. Returned as a fragment of `` elements (via a function call, + * not a nested component) so it can be embedded directly inside a `` in + * both hosts: + * + * - Desktop modal: `{settingsRouteElements()}` + * - iOS full page: `}>{settingsRouteElements()}` + * + * Retired slugs are kept as redirects so deep links keep working. + */ +export function settingsRouteElements(): ReactNode { + return ( + <> + } /> + + {/* ── General ─────────────────────────────────────────────── */} + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + {/* Real device-pairing panel (replaces the old "Coming Soon" stub). */} + )} /> + + {/* ── Assistant ───────────────────────────────────────────── */} + {/* LLM / Voice / Embeddings moved to the Connections page. */} + } /> + } /> + )} /> + } /> + )} /> + )} /> + )} /> + )} /> + {/* Top-level agent profiles (soul, memory, skills, MCP, connectors). */} + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + + {/* ── Data ────────────────────────────────────────────────── */} + )} /> + )} /> + )} /> + + {/* ── Connections ─────────────────────────────────────────── */} + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + + {/* ── System ──────────────────────────────────────────────── */} + )} /> + )} /> + + {/* ── Developer & Diagnostics leaf panels ─────────────────── */} + )} + /> + )} /> + )} /> + {/* Search engine settings moved to the Connections page. */} + } /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} + /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + {/* Knowledge & Memory panels moved to the Brain page. */} + } /> + } /> + } /> + } /> + )} /> + )} /> + + {/* ── Legacy slugs → redirects (deep-link compatibility) ──── */} + {/* Old hub pages */} + } /> + } /> + } /> + } /> + } /> + {/* Composio (API key + routing) moved to Connections → API keys. */} + } /> + {/* Merged Usage & Limits page */} + } /> + } /> + } /> + {/* Autonomy rate-limit lives inside Agent access now */} + } /> + {/* Merged Personality & Face page */} + } /> + } /> + {/* Merged Integrations page */} + } /> + } + /> + } + /> + {/* Notification routing tab */} + } + /> + {/* Fallback */} + } /> + + ); +} diff --git a/app/src/components/skills/AutocompleteSetupModal.tsx b/app/src/components/skills/AutocompleteSetupModal.tsx index c4cc0a5d3..4a6922091 100644 --- a/app/src/components/skills/AutocompleteSetupModal.tsx +++ b/app/src/components/skills/AutocompleteSetupModal.tsx @@ -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 ( diff --git a/app/src/components/skills/ScreenIntelligenceSetupModal.tsx b/app/src/components/skills/ScreenIntelligenceSetupModal.tsx index e75e054b9..f1d7077f9 100644 --- a/app/src/components/skills/ScreenIntelligenceSetupModal.tsx +++ b/app/src/components/skills/ScreenIntelligenceSetupModal.tsx @@ -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) { diff --git a/app/src/components/skills/VoiceSetupModal.tsx b/app/src/components/skills/VoiceSetupModal.tsx index 3eadfdb2c..04f0adef0 100644 --- a/app/src/components/skills/VoiceSetupModal.tsx +++ b/app/src/components/skills/VoiceSetupModal.tsx @@ -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 ( diff --git a/app/src/components/skills/__tests__/ScreenIntelligenceSetupModal.test.tsx b/app/src/components/skills/__tests__/ScreenIntelligenceSetupModal.test.tsx index 963c0d0c3..33c2ae4df 100644 --- a/app/src/components/skills/__tests__/ScreenIntelligenceSetupModal.test.tsx +++ b/app/src/components/skills/__tests__/ScreenIntelligenceSetupModal.test.tsx @@ -13,7 +13,11 @@ vi.mock('../../../features/screen-intelligence/useScreenIntelligenceState', () = vi.mock('react-router-dom', async () => { const actual = await vi.importActual>('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 () => { diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 3377868d4..e29992553 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -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')} diff --git a/app/src/pages/Rewards.tsx b/app/src/pages/Rewards.tsx index c2ce51cc5..a0b0e8c59 100644 --- a/app/src/pages/Rewards.tsx +++ b/app/src/pages/Rewards.tsx @@ -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('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))} /> diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 817c29e50..b02918a42 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -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
{children}
; -}; +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) => ( - {element} - ); - 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.
- }> - } /> - - {/* ── General ─────────────────────────────────────────────── */} - )} /> - )} /> - )} /> - )} - /> - )} - /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - {/* Real device-pairing panel (replaces the old "Coming Soon" stub). */} - )} /> - - {/* ── Assistant ───────────────────────────────────────────── */} - {/* LLM / Voice / Embeddings moved to the Connections page. */} - } /> - } - /> - )} /> - } /> - )} /> - )} /> - )} /> - )} /> - {/* Top-level agent profiles (soul, memory, skills, MCP, connectors). */} - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - - {/* ── Data ────────────────────────────────────────────────── */} - )} /> - )} /> - )} /> - - {/* ── Connections ─────────────────────────────────────────── */} - )} /> - )} - /> - )} /> - )} /> - )} /> - )} /> - )} /> - - {/* ── System ──────────────────────────────────────────────── */} - )} /> - )} /> - - {/* ── Developer & Diagnostics leaf panels ─────────────────── */} - )} - /> - )} /> - )} /> - {/* Search engine settings moved to the Connections page. */} - } /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} - /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - {/* Knowledge & Memory panels moved to the Brain page. */} - } /> - } /> - } - /> - } /> - )} /> - )} /> - - {/* ── Legacy slugs → redirects (deep-link compatibility) ──── */} - {/* Old hub pages */} - } /> - } /> - } - /> - } /> - } - /> - {/* Composio (API key + routing) moved to Connections → API keys. */} - } - /> - {/* Merged Usage & Limits page */} - } /> - } - /> - } /> - {/* Autonomy rate-limit lives inside Agent access now */} - } /> - {/* Merged Personality & Face page */} - } /> - } /> - {/* Merged Integrations page */} - } /> - } - /> - } - /> - {/* Notification routing tab */} - } - /> - {/* Fallback */} - } /> - + }>{settingsRouteElements()}
); diff --git a/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx b/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx index 9fe098b46..b18b31cb7 100644 --- a/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx +++ b/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx @@ -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('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 { @@ -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', () => { diff --git a/app/src/pages/conversations/components/TaskKanbanBoard.tsx b/app/src/pages/conversations/components/TaskKanbanBoard.tsx index 9f6445ded..ae5055b94 100644 --- a/app/src/pages/conversations/components/TaskKanbanBoard.tsx +++ b/app/src/pages/conversations/components/TaskKanbanBoard.tsx @@ -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([]); const [status, setStatus] = useState(null); @@ -704,7 +706,7 @@ function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact:
diff --git a/app/test/playwright/specs/navigation.spec.ts b/app/test/playwright/specs/navigation.spec.ts index 51818b819..efbd3d5f1 100644 --- a/app/test/playwright/specs/navigation.spec.ts +++ b/app/test/playwright/specs/navigation.spec.ts @@ -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', () => { diff --git a/app/test/playwright/specs/top-level-functional-flows.spec.ts b/app/test/playwright/specs/top-level-functional-flows.spec.ts index 91ff31275..9891833da 100644 --- a/app/test/playwright/specs/top-level-functional-flows.spec.ts +++ b/app/test/playwright/specs/top-level-functional-flows.spec.ts @@ -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 }); });