diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index cddfac0b3..8b11944f8 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -87,9 +87,9 @@ const AppRoutes = () => { } /> - {/* Back-compat: /activity and /intelligence → settings notifications hub. */} - } /> - } /> + {/* Back-compat: /activity and /intelligence → settings notifications page. */} + } /> + } /> {/* Connections page lives at /connections (Phase 2 rename from /skills). The old /skills path is kept as a back-compat redirect so bookmarks @@ -178,7 +178,7 @@ const AppRoutes = () => { } /> - } /> + } /> { const isActive = (path: string) => { if (path === '/chat') return location.pathname.startsWith('/chat'); - if (path === '/settings/cron-jobs') return location.pathname.startsWith('/settings/cron-jobs'); - if (path === '/settings/messaging') return location.pathname.startsWith('/settings/messaging'); + // Every /settings/* page lives in the two-pane settings layout — the + // Settings tab is active for all of them. (The old cron-jobs/messaging + // exclusions covered dedicated tabs that no longer exist.) if (path === '/settings') - return ( - location.pathname === '/settings' || - (location.pathname.startsWith('/settings/') && - !location.pathname.startsWith('/settings/cron-jobs') && - !location.pathname.startsWith('/settings/messaging')) - ); + return location.pathname === '/settings' || location.pathname.startsWith('/settings/'); if (path === '/home') return location.pathname === '/home'; return location.pathname === path; }; diff --git a/app/src/components/dashboard/CostDashboardPanel.tsx b/app/src/components/dashboard/CostDashboardPanel.tsx index 65631b0f3..a1a339361 100644 --- a/app/src/components/dashboard/CostDashboardPanel.tsx +++ b/app/src/components/dashboard/CostDashboardPanel.tsx @@ -18,7 +18,13 @@ import { formatCurrency, formatTokens, relativeTime } from './formatCurrency'; import ModelCostTable from './ModelCostTable'; import TokenUsageChart from './TokenUsageChart'; -const CostDashboardPanel = () => { +interface CostDashboardPanelProps { + /** When true the panel is hosted inside another settings page (e.g. the + * Usage & Limits tabs) — skip the standalone SettingsHeader chrome. */ + embedded?: boolean; +} + +const CostDashboardPanel = ({ embedded = false }: CostDashboardPanelProps) => { const { t } = useT(); const { navigateBack, breadcrumbs } = useSettingsNavigation(); const { data, isLoading, isFetching, error, lastUpdated, refetch } = useCostDashboard(); @@ -46,12 +52,14 @@ const CostDashboardPanel = () => { return (
- + {!embedded && ( + + )}

diff --git a/app/src/components/intelligence/IntelligenceTasksTab.tsx b/app/src/components/intelligence/IntelligenceTasksTab.tsx index 4620c6dc6..6d780d33b 100644 --- a/app/src/components/intelligence/IntelligenceTasksTab.tsx +++ b/app/src/components/intelligence/IntelligenceTasksTab.tsx @@ -799,7 +799,7 @@ function TaskSourceTaskList({

diff --git a/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx b/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx index 07f2de310..1688868dc 100644 --- a/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx +++ b/app/src/components/intelligence/__tests__/IntelligenceTasksTab.test.tsx @@ -273,9 +273,10 @@ describe('IntelligenceTasksTab', () => { expect(screen.getByText('No source tasks waiting.')).toBeInTheDocument(); expect(hoisted.todosList).toHaveBeenCalledWith('task-sources'); - // "Manage sources" jumps to the dedicated settings page. + // "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/task-sources'); + expect(hoisted.navigate).toHaveBeenCalledWith('/settings/integrations'); }); test('refines a source task and approves it into the personal agent board', async () => { diff --git a/app/src/components/layout/TwoPaneNav.tsx b/app/src/components/layout/TwoPaneNav.tsx new file mode 100644 index 000000000..438dbfa2e --- /dev/null +++ b/app/src/components/layout/TwoPaneNav.tsx @@ -0,0 +1,85 @@ +import type { ReactNode } from 'react'; + +export interface TwoPaneNavItem { + value: string; + label: string; + icon?: ReactNode; +} + +export interface TwoPaneNavGroup { + /** Optional uppercase sub-header above the group's items. */ + label?: string; + items: TwoPaneNavItem[]; +} + +interface TwoPaneNavProps { + groups: TwoPaneNavGroup[]; + selected: string; + onSelect: (value: string) => void; + /** Optional fixed header (title/subtitle) above the scrolling nav list. */ + header?: ReactNode; + ariaLabel?: string; +} + +/** + * Vertical, grouped tab navigation for the sidebar pane of a + * {@link TwoPanelLayout} — the left-rail counterpart to a horizontal + * PillTabBar, styled to match the settings sidebar (title header, labelled + * sub-groups, icon + label rows). The list scrolls independently below the + * optional fixed header. + */ +export default function TwoPaneNav({ + groups, + selected, + onSelect, + header, + ariaLabel, +}: TwoPaneNavProps) { + return ( + + ); +} diff --git a/app/src/components/layout/TwoPanelLayout.test.tsx b/app/src/components/layout/TwoPanelLayout.test.tsx new file mode 100644 index 000000000..3cd4abe49 --- /dev/null +++ b/app/src/components/layout/TwoPanelLayout.test.tsx @@ -0,0 +1,141 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { renderWithProviders } from '../../test/test-utils'; +import TwoPanelLayout, { useTwoPanelLayout } from './TwoPanelLayout'; + +function Sidebar() { + return
sidebar-content
; +} +function Content() { + return
main-content
; +} + +describe('TwoPanelLayout', () => { + it('renders only the content when the sidebar is hidden', () => { + renderWithProviders( + }> + + , + { + preloadedState: { + layout: { panels: { chat: { sidebarVisible: false, sidebarWidth: 256 } } }, + }, + } + ); + expect(screen.getByText('main-content')).toBeInTheDocument(); + expect(screen.queryByText('sidebar-content')).not.toBeInTheDocument(); + expect(screen.queryByTestId('two-panel-divider-chat')).not.toBeInTheDocument(); + }); + + it('renders sidebar + divider when open and applies the persisted width', () => { + renderWithProviders( + }> + + , + { + preloadedState: { + layout: { panels: { chat: { sidebarVisible: true, sidebarWidth: 300 } } }, + }, + } + ); + expect(screen.getByText('sidebar-content')).toBeInTheDocument(); + const pane = screen.getByTestId('two-panel-sidebar-chat'); + expect(pane).toHaveStyle({ width: '300px' }); + expect(screen.getByTestId('two-panel-divider-chat')).toBeInTheDocument(); + }); + + it('forceSidebarVisible overrides a hidden persisted state without mutating it', () => { + const { store } = renderWithProviders( + } forceSidebarVisible> + + , + { + preloadedState: { + layout: { panels: { chat: { sidebarVisible: false, sidebarWidth: 256 } } }, + }, + } + ); + expect(screen.getByText('sidebar-content')).toBeInTheDocument(); + expect( + (store.getState() as { layout: { panels: Record } }).layout.panels.chat + ).toEqual({ sidebarVisible: false, sidebarWidth: 256 }); + }); + + it('resizes via keyboard and clamps to the configured bounds', () => { + const { store } = renderWithProviders( + } minSidebarWidth={180} maxSidebarWidth={480}> + + , + { + preloadedState: { + layout: { panels: { chat: { sidebarVisible: true, sidebarWidth: 300 } } }, + }, + } + ); + const divider = screen.getByTestId('two-panel-divider-chat'); + const widthOf = () => + (store.getState() as { layout: { panels: Record } }).layout + .panels.chat.sidebarWidth; + + fireEvent.keyDown(divider, { key: 'ArrowRight' }); + expect(widthOf()).toBe(316); + fireEvent.keyDown(divider, { key: 'ArrowLeft' }); + expect(widthOf()).toBe(300); + }); + + it('seeds default geometry for an unseen panel via ensurePanelLayout', () => { + const { store } = renderWithProviders( + } + defaultSidebarVisible + defaultSidebarWidth={222}> + + + ); + const panel = (store.getState() as { layout: { panels: Record } }).layout + .panels.fresh; + expect(panel).toEqual({ sidebarVisible: true, sidebarWidth: 222 }); + }); + + it('shows a reopen rail when collapsed and showCollapsedRail is set', () => { + const { store } = renderWithProviders( + } showCollapsedRail> + + , + { + preloadedState: { + layout: { panels: { chat: { sidebarVisible: false, sidebarWidth: 256 } } }, + }, + } + ); + const reopen = screen.getByTestId('two-panel-reopen-chat'); + fireEvent.click(reopen); + expect( + (store.getState() as { layout: { panels: Record } }) + .layout.panels.chat.sidebarVisible + ).toBe(true); + }); +}); + +describe('useTwoPanelLayout', () => { + function Harness() { + const { sidebarVisible, toggleSidebar } = useTwoPanelLayout('chat', { sidebarVisible: false }); + return ( + + ); + } + + it('reflects and toggles the same persisted state', () => { + renderWithProviders(); + const btn = screen.getByRole('button'); + expect(btn).toHaveTextContent('closed'); + fireEvent.click(btn); + expect(btn).toHaveTextContent('open'); + fireEvent.click(btn); + expect(btn).toHaveTextContent('closed'); + }); +}); diff --git a/app/src/components/layout/TwoPanelLayout.tsx b/app/src/components/layout/TwoPanelLayout.tsx new file mode 100644 index 000000000..a584fc87b --- /dev/null +++ b/app/src/components/layout/TwoPanelLayout.tsx @@ -0,0 +1,303 @@ +import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { + ensurePanelLayout, + type PanelLayout, + selectPanelLayout, + setSidebarVisible, + setSidebarWidth, + toggleSidebar, +} from '../../store/layoutSlice'; + +const namespace = 'two-panel-layout'; + +function debug(message: string, payload?: Record) { + if (import.meta.env.DEV) { + console.debug(`[${namespace}] ${message}`, payload ?? {}); + } +} + +function clampWidth(width: number, min: number, max: number): number { + return Math.min(Math.max(width, min), max); +} + +/** + * Subscribe to a two-pane layout's persisted geometry and get back the + * helpers external chrome needs to drive it (e.g. a hamburger button living + * in some other header). Reads the SAME slice state `TwoPanelLayout` renders + * from, so toggles stay in sync. + */ +export function useTwoPanelLayout(id: string, defaults?: Partial) { + const dispatch = useAppDispatch(); + const layout = useAppSelector(selectPanelLayout(id, defaults)); + + const show = useCallback( + (visible: boolean) => dispatch(setSidebarVisible({ id, visible })), + [dispatch, id] + ); + const toggle = useCallback(() => dispatch(toggleSidebar({ id })), [dispatch, id]); + + return { + sidebarVisible: layout.sidebarVisible, + sidebarWidth: layout.sidebarWidth, + showSidebar: show, + toggleSidebar: toggle, + }; +} + +export interface TwoPanelLayoutProps { + /** Stable id used as the persistence key for this layout's geometry. */ + id: string; + /** Content of the mini sidebar (left pane). */ + sidebar: ReactNode; + /** Main content (right pane). */ + children: ReactNode; + /** Sidebar visibility on first ever mount (before any persisted state). */ + defaultSidebarVisible?: boolean; + /** Sidebar width in px on first ever mount. */ + defaultSidebarWidth?: number; + /** Minimum sidebar width while dragging. */ + minSidebarWidth?: number; + /** Maximum sidebar width while dragging. */ + maxSidebarWidth?: number; + /** + * Force the sidebar open regardless of persisted state (e.g. an onboarding + * lockdown where the sidebar must always show). The persisted preference is + * untouched, so it restores once the force is lifted. + */ + forceSidebarVisible?: boolean; + /** Step (px) the keyboard divider moves per arrow press. */ + keyboardStep?: number; + className?: string; + sidebarClassName?: string; + contentClassName?: string; + /** + * Shared appearance applied to BOTH panes — the card background, rounded + * corners, border and shadow live here (not in the panes' own content) so + * every two-pane screen gets a consistent look for free. Pass `''` to opt + * out (e.g. a flush, borderless layout). + */ + paneClassName?: string; + /** + * Show a thin rail with a reopen button when the sidebar is hidden. Defaults + * to false because chat surfaces its own toggle in the header; standalone + * uses can opt in. + */ + showCollapsedRail?: boolean; + /** + * Show the visible grab handle on the resize divider. When false the divider + * is still draggable (and shows a faint line on hover/focus) but renders no + * resting holder — a cleaner look for screens that don't want the affordance + * front-and-center. Defaults to true. + */ + showDividerHandle?: boolean; +} + +/** Default card look shared by both panes. */ +export const DEFAULT_PANE_CLASS = + 'bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800'; + +const DEFAULT_MIN_WIDTH = 180; +const DEFAULT_MAX_WIDTH = 480; +const DEFAULT_KEYBOARD_STEP = 16; + +/** + * A reusable two-pane shell: a resizable mini sidebar on the left and main + * content on the right. Visibility and the dragged width persist per `id` via + * the Redux `layout` slice, so the layout is remembered across reloads. + * + * Resize: drag the divider between the panes (pointer) or focus it and use the + * arrow keys. Width is clamped to [minSidebarWidth, maxSidebarWidth] and only + * committed to the store on drag end to avoid thrashing redux-persist. + */ +export default function TwoPanelLayout({ + id, + sidebar, + children, + defaultSidebarVisible = false, + defaultSidebarWidth, + minSidebarWidth = DEFAULT_MIN_WIDTH, + maxSidebarWidth = DEFAULT_MAX_WIDTH, + forceSidebarVisible = false, + keyboardStep = DEFAULT_KEYBOARD_STEP, + className = '', + sidebarClassName = '', + contentClassName = '', + paneClassName = DEFAULT_PANE_CLASS, + showCollapsedRail = false, + showDividerHandle = true, +}: TwoPanelLayoutProps) { + const { t } = useT(); + const dispatch = useAppDispatch(); + const layout = useAppSelector( + selectPanelLayout(id, { + sidebarVisible: defaultSidebarVisible, + ...(defaultSidebarWidth != null ? { sidebarWidth: defaultSidebarWidth } : {}), + }) + ); + + // Seed persisted geometry from this component's defaults exactly once per id. + useEffect(() => { + dispatch( + ensurePanelLayout({ + id, + defaults: { + sidebarVisible: defaultSidebarVisible, + ...(defaultSidebarWidth != null ? { sidebarWidth: defaultSidebarWidth } : {}), + }, + }) + ); + // Intentionally only on id change — defaults are a first-mount seed. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + const isOpen = forceSidebarVisible || layout.sidebarVisible; + + // Live width while dragging is kept local (and applied via inline style) so + // we don't dispatch — and re-persist — on every pointer move. + const [dragWidth, setDragWidth] = useState(null); + const dragWidthRef = useRef(null); + const persistedWidth = clampWidth(layout.sidebarWidth, minSidebarWidth, maxSidebarWidth); + const width = dragWidth ?? persistedWidth; + + const commitWidth = useCallback( + (next: number) => { + const clamped = clampWidth(Math.round(next), minSidebarWidth, maxSidebarWidth); + dispatch(setSidebarWidth({ id, width: clamped })); + debug('commit width', { id, width: clamped }); + }, + [dispatch, id, minSidebarWidth, maxSidebarWidth] + ); + + // Active-drag teardown, stashed so an unmount mid-drag can detach the global + // listeners. Each drag installs locally-scoped `pointermove`/`pointerup` + // handlers (hoisted function declarations so they can reference each other), + // keeping the resize self-contained without inter-callback dependencies. + const dragCleanupRef = useRef<(() => void) | null>(null); + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + const startX = e.clientX; + const startWidth = width; + dragWidthRef.current = startWidth; + setDragWidth(startWidth); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + function handleMove(ev: PointerEvent) { + const next = clampWidth( + startWidth + (ev.clientX - startX), + minSidebarWidth, + maxSidebarWidth + ); + dragWidthRef.current = next; + setDragWidth(next); + } + function detach() { + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', stop); + document.body.style.removeProperty('cursor'); + document.body.style.removeProperty('user-select'); + dragCleanupRef.current = null; + } + function stop() { + detach(); + const finalWidth = dragWidthRef.current; + dragWidthRef.current = null; + setDragWidth(null); + if (finalWidth != null) commitWidth(finalWidth); + } + + dragCleanupRef.current = detach; + window.addEventListener('pointermove', handleMove); + window.addEventListener('pointerup', stop); + debug('drag start', { id, startWidth }); + }, + [width, minSidebarWidth, maxSidebarWidth, commitWidth, id] + ); + + // Detach global listeners if we unmount mid-drag. + useLayoutEffect(() => { + return () => { + dragCleanupRef.current?.(); + }; + }, []); + + const onDividerKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + e.preventDefault(); + commitWidth(persistedWidth - keyboardStep); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + commitWidth(persistedWidth + keyboardStep); + } + }, + [commitWidth, persistedWidth, keyboardStep] + ); + + return ( +
+ {isOpen && ( + <> +
+ {sidebar} +
+ + {/* Drag handle / divider */} +
+ {/* Transparent hit area (full height) with a short grab handle + centered vertically. When the handle is hidden it stays + transparent at rest and only surfaces on hover/focus. */} + +
+ + )} + + {!isOpen && showCollapsedRail && ( + + )} + +
+ {children} +
+
+ ); +} diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx deleted file mode 100644 index 1408136bc..000000000 --- a/app/src/components/settings/SettingsHome.tsx +++ /dev/null @@ -1,460 +0,0 @@ -import { type ReactNode, useState } from 'react'; - -import { useT } from '../../lib/i18n/I18nContext'; -import LanguageSelect from '../LanguageSelect'; -import SettingsHeader from './components/SettingsHeader'; -import SettingsMenuItem from './components/SettingsMenuItem'; -import { useSettingsNavigation } from './hooks/useSettingsNavigation'; -import SettingsSearchBar from './search/SettingsSearchBar'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -interface SettingsItem { - id: string; - title: string; - description: string; - icon: ReactNode; - onClick?: () => void; - dangerous?: boolean; - rightElement?: ReactNode; -} - -interface SettingsGroup { - /** Stable identifier for testing and key prop */ - id: string; - /** i18n label shown above the card */ - label: string; - items: SettingsItem[]; -} - -// --------------------------------------------------------------------------- -// Icon helpers (inline SVG kept as constants to avoid duplication) -// --------------------------------------------------------------------------- - -const AccountIcon = ( - - - -); - -const LanguageIcon = ( - - - -); - -const AppearanceIcon = ( - - - -); - -const DevicesIcon = ( - - - -); - -const PersonalityIcon = ( - - - -); - -const MascotIcon = ( - - - -); - -const NotificationsIcon = ( - - - -); - -const DeveloperIcon = ( - - - -); - -const AboutIcon = ( - - - -); - -const DataSyncIcon = ( - - - -); - -const AiIcon = ( - - - -); - -const AgentsIcon = ( - - - -); - -const FeaturesIcon = ( - - - - -); - -const IntegrationsIcon = ( - - - -); - -const CryptoIcon = ( - - - -); - -// --------------------------------------------------------------------------- -// Group header (visual separator label above each settings card) -// --------------------------------------------------------------------------- - -const GroupHeader = ({ label }: { label: string }) => - label ? ( -
- - {label} - -
- ) : ( - // Empty label → a plain divider (Developer & Diagnostics and About sit - // after a divider, not under their own section headers). -
- ); - -// --------------------------------------------------------------------------- -// Main component -// --------------------------------------------------------------------------- - -const SettingsHome = () => { - const { navigateToSettings } = useSettingsNavigation(); - const { t } = useT(); - - // Global settings search. While a query is active the normal menu is hidden - // and the search bar renders its own ranked result list instead. - const [searchQuery, setSearchQuery] = useState(''); - const isSearching = searchQuery.trim().length > 0; - - // --- Account group items --- - // Account (hub), Language (inline), Appearance, Devices, Data Sync. - const accountItems: SettingsItem[] = [ - { - id: 'profile', - title: t('pages.settings.accountSection.title'), - description: t('pages.settings.accountSection.description'), - icon: AccountIcon, - onClick: () => navigateToSettings('account'), - }, - { - id: 'language', - title: t('settings.language'), - description: t('settings.languageDesc'), - icon: LanguageIcon, - rightElement: , - }, - { - id: 'appearance', - title: t('settings.appearance.title'), - description: t('settings.appearance.menuDesc'), - icon: AppearanceIcon, - onClick: () => navigateToSettings('appearance'), - }, - { - id: 'devices', - title: t('settings.account.devices'), - description: t('settings.account.devicesDesc'), - icon: DevicesIcon, - onClick: () => navigateToSettings('devices'), - }, - { - id: 'data-sync', - title: t('settings.dataSync.title'), - description: t('settings.dataSync.menuDesc'), - icon: DataSyncIcon, - onClick: () => navigateToSettings('memory-sync'), - }, - ]; - - // --- Assistant group items --- - // AI & Models, Agents, Personality, Face & Mascot. - const assistantItems: SettingsItem[] = [ - { - id: 'ai', - title: t('pages.settings.aiSection.title'), - description: t('pages.settings.aiSection.description'), - icon: AiIcon, - onClick: () => navigateToSettings('ai'), - }, - { - id: 'agents-settings', - title: t('settings.agentsSection.title'), - description: t('settings.agentsSection.menuDesc'), - icon: AgentsIcon, - onClick: () => navigateToSettings('agents-settings'), - }, - { - id: 'profiles', - title: t('settings.profiles.title'), - description: t('settings.profiles.menuDesc'), - icon: PersonalityIcon, - onClick: () => navigateToSettings('profiles'), - }, - { - id: 'persona', - title: t('settings.assistant.personality'), - description: t('settings.assistant.personalityDesc'), - icon: PersonalityIcon, - onClick: () => navigateToSettings('persona'), - }, - { - id: 'mascot', - title: t('settings.assistant.faceMascot'), - description: t('settings.assistant.faceMascotDesc'), - icon: MascotIcon, - onClick: () => navigateToSettings('mascot'), - }, - ]; - - // --- Features & Integrations group items --- - // Features section, Composio/Integrations section. - const featuresIntegrationsItems: SettingsItem[] = [ - { - id: 'features', - title: t('pages.settings.featuresSection.title'), - description: t('pages.settings.featuresSection.description'), - icon: FeaturesIcon, - onClick: () => navigateToSettings('features'), - }, - { - id: 'composio', - title: t('pages.settings.composioSection.title'), - description: t('pages.settings.composioSection.description'), - icon: IntegrationsIcon, - onClick: () => navigateToSettings('composio'), - }, - ]; - - // --- Notifications group --- - const notificationsItems: SettingsItem[] = [ - { - id: 'notifications-hub', - title: t('settings.notifications.menuTitle'), - description: t('settings.notifications.menuDesc'), - icon: NotificationsIcon, - onClick: () => navigateToSettings('notifications-hub'), - }, - ]; - - // --- Crypto group --- - const cryptoItems: SettingsItem[] = [ - { - id: 'crypto', - title: t('settings.cryptoSection.title'), - description: t('settings.cryptoSection.menuDesc'), - icon: CryptoIcon, - onClick: () => navigateToSettings('crypto'), - }, - ]; - - // The layman-facing merged card combines: Account, Assistant, - // Features & Integrations, Notifications, Crypto rows in one flat card. - const laymanItems: SettingsItem[] = [ - ...accountItems, - ...assistantItems, - ...featuresIntegrationsItems, - ...notificationsItems, - ...cryptoItems, - ]; - - // --- About group (always visible; no section header — just a divider) --- - const aboutGroup: SettingsGroup = { - id: 'about', - label: '', - items: [ - { - id: 'about', - title: t('settings.about'), - description: t('settings.aboutDesc'), - icon: AboutIcon, - onClick: () => navigateToSettings('about'), - }, - ], - }; - - // --- Developer & Diagnostics (always visible) --- - const developerGroup: SettingsGroup = { - id: 'developer', - label: '', - items: [ - { - id: 'developer-options', - title: t('settings.developerDiagnostics'), - description: t('settings.developerDiagnosticsDesc'), - icon: DeveloperIcon, - onClick: () => navigateToSettings('developer-options'), - }, - ], - }; - - const trailingGroups: SettingsGroup[] = [developerGroup, aboutGroup]; - - return ( -
-
- -
- - - - {/* While searching, the search bar renders its own results and the normal - settings menu is hidden to avoid a confusing double list. */} - {isSearching ? null : ( -
- {/* Merged layman card — Account / Assistant / Features & Integrations / - Notifications / Crypto in one flat card. No sub-section headers. */} -
- {laymanItems.map((item, index) => ( - - ))} -
- - {trailingGroups.map(group => ( -
- -
- {group.items.map((item, index) => ( - - ))} -
-
- ))} -
- )} -
- ); -}; - -export default SettingsHome; diff --git a/app/src/components/settings/SettingsSectionPage.tsx b/app/src/components/settings/SettingsSectionPage.tsx deleted file mode 100644 index 1549adad2..000000000 --- a/app/src/components/settings/SettingsSectionPage.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import type { ReactNode } from 'react'; - -import SettingsHeader from './components/SettingsHeader'; -import SettingsMenuItem from './components/SettingsMenuItem'; -import { useSettingsNavigation } from './hooks/useSettingsNavigation'; - -export interface SettingsSectionItem { - id: string; - title: string; - description?: string; - icon: ReactNode; - /** - * Settings sub-route to navigate to (under `/settings/`). Optional when an - * explicit `onClick` is supplied — e.g. an item that links to a top-level - * route outside the settings tree (the Alerts inbox at `/notifications`). - */ - route?: string; - /** Overrides the default `navigateToSettings(route)` navigation when set. */ - onClick?: () => void; -} - -interface SettingsSectionPageProps { - title: string; - description?: string; - items: SettingsSectionItem[]; - /** Optional content rendered below the items list (e.g. destructive actions). */ - footer?: ReactNode; -} - -const SettingsSectionPage = ({ title, description, items, footer }: SettingsSectionPageProps) => { - const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation(); - - return ( -
- - - {/* Mirror the SettingsHome layout: padded container, items in a single - rounded-border card, and the optional footer in its own matching card - so section pages and the home list look identical. */} -
- {description && ( -

{description}

- )} - -
- {items.map((item, index) => ( - item.route && navigateToSettings(item.route))} - testId={`settings-nav-${item.id}`} - isFirst={index === 0} - isLast={index === items.length - 1} - /> - ))} -
- - {footer && ( - <> - {/* Divider + card, mirroring how SettingsHome separates its - trailing groups (e.g. the destructive logout/clear card). */} -
-
- {footer} -
- - )} -
-
- ); -}; - -export default SettingsSectionPage; diff --git a/app/src/components/settings/__tests__/SettingsHome.test.tsx b/app/src/components/settings/__tests__/SettingsHome.test.tsx deleted file mode 100644 index f3bd8a575..000000000 --- a/app/src/components/settings/__tests__/SettingsHome.test.tsx +++ /dev/null @@ -1,399 +0,0 @@ -import { configureStore } from '@reduxjs/toolkit'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { Provider } from 'react-redux'; -import { MemoryRouter } from 'react-router-dom'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { I18nProvider } from '../../../lib/i18n/I18nContext'; -import type { Locale } from '../../../lib/i18n/types'; -import localeReducer from '../../../store/localeSlice'; -import themeReducer, { - type AgentMessageViewMode, - type FontSize, - type TabBarLabels, - type ThemeMode, -} from '../../../store/themeSlice'; -import SettingsHome from '../SettingsHome'; - -// `useDeveloperMode` combines IS_DEV || developerMode. In tests IS_DEV is -// true (Vite test mode), so mock the hook to control the gate explicitly. -const devModeHoisted = vi.hoisted(() => ({ value: false })); -vi.mock('../../../hooks/useDeveloperMode', () => ({ - useDeveloperMode: () => devModeHoisted.value, -})); - -function makeTestStore(locale: Locale = 'en', developerMode = false) { - return configureStore({ - reducer: { locale: localeReducer, theme: themeReducer }, - preloadedState: { - locale: { current: locale }, - theme: { - mode: 'system' as ThemeMode, - tabBarLabels: 'hover' as TabBarLabels, - fontSize: 'medium' as FontSize, - agentMessageViewMode: 'bubbles' as AgentMessageViewMode, - developerMode, - }, - }, - }); -} - -// --- hoisted mocks --- - -const { mockNavigate, mockNavigateToSettings } = vi.hoisted(() => ({ - mockNavigate: vi.fn(), - mockNavigateToSettings: vi.fn(), -})); - -vi.mock('react-router-dom', async importOriginal => { - const actual = await importOriginal(); - return { ...actual, useNavigate: () => mockNavigate }; -}); - -vi.mock('../hooks/useSettingsNavigation', () => ({ - useSettingsNavigation: () => ({ navigateToSettings: mockNavigateToSettings }), -})); - -const mockClearSession = vi.fn().mockResolvedValue(undefined); -let mockSessionToken: string | null = null; - -vi.mock('../../../providers/CoreStateProvider', () => ({ - useCoreState: () => ({ - clearSession: mockClearSession, - snapshot: { auth: { userId: null }, currentUser: null, sessionToken: mockSessionToken }, - }), -})); - -vi.mock('../../../store', () => ({ persistor: { purge: vi.fn().mockResolvedValue(undefined) } })); - -vi.mock('../../../utils/links', () => ({ BILLING_DASHBOARD_URL: 'https://billing.example.com' })); - -vi.mock('../../../utils/openUrl', () => ({ openUrl: vi.fn().mockResolvedValue(undefined) })); - -vi.mock('../../../utils/tauriCommands', () => ({ - resetOpenHumanDataAndRestartCore: vi.fn().mockResolvedValue(undefined), - restartApp: vi.fn().mockResolvedValue(undefined), - scheduleCefProfilePurge: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('../../walkthrough/AppWalkthrough', () => ({ resetWalkthrough: vi.fn() })); - -// --- helpers --- - -function renderSettingsHome({ locale = 'en', withI18n = false, developerMode = false } = {}) { - // Set the mocked hook value before rendering. - devModeHoisted.value = developerMode; - - const content = withI18n ? ( - - - - ) : ( - - ); - - return render( - - {content} - - ); -} - -// --- tests --- - -describe('SettingsHome', () => { - beforeEach(() => { - vi.clearAllMocks(); - devModeHoisted.value = false; - }); - - describe('layman groups structure', () => { - it('renders the merged layman card and the About container', () => { - renderSettingsHome(); - // The layman groups (Account/Assistant/Privacy/Notifications) merge into a - // single card with no subheadings; About keeps its own container. - expect(screen.getByTestId('settings-group-main')).toBeInTheDocument(); - expect(screen.getByTestId('settings-group-about')).toBeInTheDocument(); - expect(screen.queryByTestId('settings-group-account')).not.toBeInTheDocument(); - expect(screen.queryByTestId('settings-group-assistant')).not.toBeInTheDocument(); - }); - - it('renders the Account group items', () => { - renderSettingsHome(); - // Account group: Account (hub), Language, Appearance, Devices, Data Sync. - // Team & members and Data & migration moved out (dev / removed). - expect(screen.getByTestId('settings-nav-profile')).toBeInTheDocument(); - expect(screen.getByTestId('settings-nav-language')).toBeInTheDocument(); - expect(screen.getByTestId('settings-nav-appearance')).toBeInTheDocument(); - expect(screen.getByTestId('settings-nav-devices')).toBeInTheDocument(); - expect(screen.getByTestId('settings-nav-data-sync')).toBeInTheDocument(); - expect(screen.queryByTestId('settings-nav-team')).not.toBeInTheDocument(); - expect(screen.queryByTestId('settings-nav-migration')).not.toBeInTheDocument(); - }); - - it('renders the Assistant group items', () => { - renderSettingsHome(); - // Only Personality + Face/Mascot stay layman-facing; the rest moved to - // Developer & Diagnostics. - expect(screen.getByTestId('settings-nav-persona')).toBeInTheDocument(); - expect(screen.getByTestId('settings-nav-mascot')).toBeInTheDocument(); - expect(screen.queryByTestId('settings-nav-voice')).not.toBeInTheDocument(); - expect(screen.queryByTestId('settings-nav-permissions')).not.toBeInTheDocument(); - expect(screen.queryByTestId('settings-nav-activity-level')).not.toBeInTheDocument(); - expect(screen.queryByTestId('settings-nav-screen-intelligence')).not.toBeInTheDocument(); - expect(screen.queryByTestId('settings-nav-companion')).not.toBeInTheDocument(); - }); - - it('does not surface Privacy/Security/Approvals on the home list', () => { - renderSettingsHome(); - // Privacy is reached via the Account hub; Security + Approvals live under - // Developer & Diagnostics. None of them are top-level home rows. - expect(screen.queryByTestId('settings-nav-privacy')).not.toBeInTheDocument(); - expect(screen.queryByTestId('settings-nav-security')).not.toBeInTheDocument(); - expect(screen.queryByTestId('settings-nav-approval-history')).not.toBeInTheDocument(); - }); - - it('renders the Notifications group item', () => { - renderSettingsHome(); - expect(screen.getByTestId('settings-nav-notifications-hub')).toBeInTheDocument(); - }); - - it('renders the About item always (even without developer mode)', () => { - renderSettingsHome({ developerMode: false }); - expect(screen.getByTestId('settings-nav-about')).toBeInTheDocument(); - }); - - it('old flat section headers are not rendered', () => { - // Section headers ("General", "Features & AI", "Billing & Rewards", - // "Support", "Danger Zone") were removed in Phase 4. - renderSettingsHome(); - expect(screen.queryByText('Features & AI')).not.toBeInTheDocument(); - expect(screen.queryByText('Support')).not.toBeInTheDocument(); - expect(screen.queryByText('Danger Zone')).not.toBeInTheDocument(); - }); - }); - - describe('items no longer on the home screen', () => { - it('renders Agents and Crypto section hubs on the home screen', () => { - // Pass A surfaced agents-settings and crypto on the home screen as part of - // the merged layman card (assistant group + crypto group). - renderSettingsHome(); - expect(screen.getByTestId('settings-nav-agents-settings')).toBeInTheDocument(); - expect(screen.getByTestId('settings-nav-crypto')).toBeInTheDocument(); - }); - - it('no longer renders Alerts / stand-alone Notifications on the home screen', () => { - // Notifications now lives in its own Notifications group (notifications-hub). - renderSettingsHome(); - expect(screen.queryByTestId('settings-nav-alerts')).not.toBeInTheDocument(); - expect(screen.queryByTestId('settings-nav-notifications')).not.toBeInTheDocument(); - }); - - it('no longer renders destructive actions on the home screen', () => { - // Clear App Data + Log out moved to Settings → Account. - renderSettingsHome(); - expect(screen.queryByText('Clear App Data')).not.toBeInTheDocument(); - expect(screen.queryByText('Log out')).not.toBeInTheDocument(); - }); - - it('renders Features and AI section hubs on home; Rewards and Restart Tour remain absent', () => { - // Pass A moved Features and AI onto the home screen as merged-card entries. - // Rewards and Restart Tour are not home items (Rewards lives in the avatar - // menu; Restart Tour is in Developer & Diagnostics only). - renderSettingsHome(); - expect(screen.getByTestId('settings-nav-features')).toBeInTheDocument(); - expect(screen.getByTestId('settings-nav-ai')).toBeInTheDocument(); - expect(screen.queryByText('Rewards')).not.toBeInTheDocument(); - expect(screen.queryByText('Restart Tour')).not.toBeInTheDocument(); - }); - }); - - describe('language selector', () => { - it('offers Bahasa Indonesia as a display language', () => { - renderSettingsHome(); - expect(screen.getByRole('option', { name: /Bahasa Indonesia/ })).toHaveValue('id'); - }); - }); - - describe('navigation — layman groups', () => { - it('navigates to account settings when Profile is clicked', async () => { - const user = userEvent.setup(); - renderSettingsHome(); - - await user.click(screen.getByTestId('settings-nav-profile')); - expect(mockNavigateToSettings).toHaveBeenCalledWith('account'); - }); - - it('navigates to persona when Personality is clicked', async () => { - const user = userEvent.setup(); - renderSettingsHome({ withI18n: true }); - - await user.click(screen.getByTestId('settings-nav-persona')); - expect(mockNavigateToSettings).toHaveBeenCalledWith('persona'); - }); - - it('navigates to mascot when Face / Mascot is clicked', async () => { - const user = userEvent.setup(); - renderSettingsHome(); - - await user.click(screen.getByTestId('settings-nav-mascot')); - expect(mockNavigateToSettings).toHaveBeenCalledWith('mascot'); - }); - - it('navigates to notifications-hub when Notifications is clicked', async () => { - const user = userEvent.setup(); - renderSettingsHome(); - - await user.click(screen.getByTestId('settings-nav-notifications-hub')); - expect(mockNavigateToSettings).toHaveBeenCalledWith('notifications-hub'); - }); - - it('navigates to about when About is clicked', async () => { - const user = userEvent.setup(); - renderSettingsHome(); - - await user.click(screen.getByTestId('settings-nav-about')); - expect(mockNavigateToSettings).toHaveBeenCalledWith('about'); - }); - - it('does not render Billing & Usage in Settings (billing is in avatar menu)', () => { - // Per the IA redesign doc, billing/rewards live in the avatar menu — not in Settings. - renderSettingsHome(); - expect(screen.queryByTestId('settings-nav-billing')).not.toBeInTheDocument(); - expect(screen.queryByText('Billing & Usage')).not.toBeInTheDocument(); - }); - - it('navigates to developer-options when "Developer & Diagnostics" is clicked', async () => { - const user = userEvent.setup(); - renderSettingsHome(); - - await user.click(screen.getByTestId('settings-nav-developer-options')); - expect(mockNavigateToSettings).toHaveBeenCalledWith('developer-options'); - }); - }); - - describe('developer & diagnostics (always visible)', () => { - it('shows the developer-options entry regardless of the developerMode preference', () => { - renderSettingsHome({ developerMode: false }); - expect(screen.getByTestId('settings-nav-developer-options')).toBeInTheDocument(); - // useT() resolves to English even without I18nProvider - expect(screen.getByText('Developer & Diagnostics')).toBeInTheDocument(); - }); - }); - - describe('local session gating', () => { - beforeEach(() => { - // Use a valid local-session token (three parts, last part = 'local') - mockSessionToken = 'header.payload.local'; - }); - - afterEach(() => { - mockSessionToken = null; - }); - - it('does not render Billing & Usage in Settings regardless of session type (billing is in avatar menu)', () => { - // Billing moved to avatar menu per IA redesign — never shown in Settings. - renderSettingsHome(); - expect(screen.queryByText('Billing & Usage')).not.toBeInTheDocument(); - }); - - it('does not render Billing & Usage in Settings even when not in local mode', () => { - // Billing moved to avatar menu per IA redesign — never shown in Settings. - mockSessionToken = null; - renderSettingsHome(); - expect(screen.queryByText('Billing & Usage')).not.toBeInTheDocument(); - }); - }); - - describe('i18n — Chinese locale', () => { - it('localizes Appearance and Mascot menu items', () => { - renderSettingsHome({ locale: 'zh-CN', withI18n: true }); - - expect(screen.getByText('外观')).toBeInTheDocument(); - expect(screen.getByText('选择浅色、深色或跟随系统主题')).toBeInTheDocument(); - }); - }); - - // --------------------------------------------------------------------------- - // Pass A — newly-surfaced section entries - // --------------------------------------------------------------------------- - - describe('Pass A section hubs', () => { - it('renders the 5 newly-surfaced section entry hubs in the merged card', () => { - // Pass A merged AI, Agents, Features, Integrations (composio), and Crypto - // directly onto the home screen as section-hub entries. All 5 must render - // as navigable items in the settings-group-main card. - renderSettingsHome(); - expect(screen.getByTestId('settings-nav-ai')).toBeInTheDocument(); - expect(screen.getByTestId('settings-nav-agents-settings')).toBeInTheDocument(); - expect(screen.getByTestId('settings-nav-features')).toBeInTheDocument(); - expect(screen.getByTestId('settings-nav-composio')).toBeInTheDocument(); - expect(screen.getByTestId('settings-nav-crypto')).toBeInTheDocument(); - }); - - it('clicking ai hub navigates to ai', async () => { - const user = userEvent.setup(); - renderSettingsHome(); - await user.click(screen.getByTestId('settings-nav-ai')); - expect(mockNavigateToSettings).toHaveBeenCalledWith('ai'); - }); - - it('clicking agents-settings hub navigates to agents-settings', async () => { - const user = userEvent.setup(); - renderSettingsHome(); - await user.click(screen.getByTestId('settings-nav-agents-settings')); - expect(mockNavigateToSettings).toHaveBeenCalledWith('agents-settings'); - }); - - it('clicking features hub navigates to features', async () => { - const user = userEvent.setup(); - renderSettingsHome(); - await user.click(screen.getByTestId('settings-nav-features')); - expect(mockNavigateToSettings).toHaveBeenCalledWith('features'); - }); - - it('clicking composio hub navigates to composio', async () => { - const user = userEvent.setup(); - renderSettingsHome(); - await user.click(screen.getByTestId('settings-nav-composio')); - expect(mockNavigateToSettings).toHaveBeenCalledWith('composio'); - }); - - it('clicking crypto hub navigates to crypto', async () => { - const user = userEvent.setup(); - renderSettingsHome(); - await user.click(screen.getByTestId('settings-nav-crypto')); - expect(mockNavigateToSettings).toHaveBeenCalledWith('crypto'); - }); - }); - - describe('navigation — account group items (lines 261, 268, 275)', () => { - it('navigates to devices when Devices is clicked (line 268)', async () => { - const user = userEvent.setup(); - renderSettingsHome(); - - await user.click(screen.getByTestId('settings-nav-devices')); - expect(mockNavigateToSettings).toHaveBeenCalledWith('devices'); - }); - - it('navigates to memory-sync when Data Sync is clicked (line 275)', async () => { - const user = userEvent.setup(); - renderSettingsHome(); - - await user.click(screen.getByTestId('settings-nav-data-sync')); - expect(mockNavigateToSettings).toHaveBeenCalledWith('memory-sync'); - }); - - it('navigates to appearance when Appearance is clicked (line 261)', async () => { - const user = userEvent.setup(); - renderSettingsHome(); - - await user.click(screen.getByTestId('settings-nav-appearance')); - expect(mockNavigateToSettings).toHaveBeenCalledWith('appearance'); - }); - }); - - // Clear App Data flow moved to LogoutAndClearActions (rendered on Account - // page) — see LogoutAndClearActions.test.tsx. -}); diff --git a/app/src/components/settings/__tests__/settingsRouteRegistry.test.ts b/app/src/components/settings/__tests__/settingsRouteRegistry.test.ts index 2ec4e304d..d674e4764 100644 --- a/app/src/components/settings/__tests__/settingsRouteRegistry.test.ts +++ b/app/src/components/settings/__tests__/settingsRouteRegistry.test.ts @@ -28,9 +28,9 @@ describe('entryRoute', () => { }); it('falls back to the id when no explicit route is set', () => { - const entry = findEntryById('persona'); + const entry = findEntryById('personality'); expect(entry).toBeDefined(); - expect(entryRoute(entry!)).toBe('persona'); + expect(entryRoute(entry!)).toBe('personality'); }); it('returns the overridden route for build-info (→ about)', () => { @@ -55,8 +55,10 @@ describe('findEntryById', () => { expect(findEntryById('does-not-exist')).toBeUndefined(); }); - it('returns the correct section for agents-settings', () => { - const entry = findEntryById('agents-settings'); + it('returns the correct section for a home hub entry', () => { + // The old 'agents-settings' / 'ai' hub pages were retired; 'integrations' + // is a representative surviving home-section hub. + const entry = findEntryById('integrations'); expect(entry).toBeDefined(); expect(entry!.section).toBe('home'); }); @@ -75,9 +77,9 @@ describe('findEntryById', () => { describe('findEntryByRoute', () => { it('returns an entry for a known route', () => { - const entry = findEntryByRoute('persona'); + const entry = findEntryByRoute('personality'); expect(entry).toBeDefined(); - expect(entry!.id).toBe('persona'); + expect(entry!.id).toBe('personality'); }); it('returns undefined for an unknown route', () => { @@ -92,12 +94,15 @@ describe('findEntryByRoute', () => { expect(entry).toBeDefined(); }); - it('does not match partial/substring routes — no collision between "ai" and "ai-debug"', () => { - const entry = findEntryByRoute('ai'); + it('does not match partial/substring routes — no collision between "voice" and "voice-debug"', () => { + const entry = findEntryByRoute('voice'); expect(entry).toBeDefined(); - expect(entry!.id).toBe('ai'); - // There should be no entry with id 'ai-debug' in the registry. - expect(findEntryByRoute('ai-debug')).toBeUndefined(); + expect(entry!.id).toBe('voice'); + // 'voice-debug' is a distinct developer entry; exact-match lookup must not + // collide with the 'voice' leaf despite the shared prefix. + const debugEntry = findEntryByRoute('voice-debug'); + expect(debugEntry).toBeDefined(); + expect(debugEntry!.id).toBe('voice-debug'); }); }); @@ -113,18 +118,23 @@ describe('entriesForSection', () => { }); it('excludes hidden deep-links', () => { - // 'approval-history' is section: 'agents' + hiddenDeepLink: true. - const agentEntries = entriesForSection('agents'); - const ids = agentEntries.map(e => e.id); - expect(ids).not.toContain('approval-history'); + // 'autocomplete' and 'permissions' are section: 'developer' + hiddenDeepLink. + const devEntries = entriesForSection('developer'); + const ids = devEntries.map(e => e.id); + expect(ids).not.toContain('autocomplete'); + expect(ids).not.toContain('permissions'); }); - it('returns the composio section entries', () => { - const composioEntries = entriesForSection('composio'); - const ids = composioEntries.map(e => e.id); - expect(ids).toContain('task-sources'); - expect(ids).toContain('composio-routing'); - expect(ids).toContain('webhooks-triggers'); + it('surfaces the merged integrations entry on home (composio section retired)', () => { + const homeEntries = entriesForSection('home'); + const ids = homeEntries.map(e => e.id); + expect(ids).toContain('integrations'); + // The old composio leaf slugs redirect to /settings/integrations and are + // no longer registry entries. + const allIds = SETTINGS_ROUTE_REGISTRY.map(e => e.id); + expect(allIds).not.toContain('task-sources'); + expect(allIds).not.toContain('composio-routing'); + expect(allIds).not.toContain('webhooks-triggers'); }); it('returns multiple developer entries', () => { @@ -139,17 +149,19 @@ describe('entriesForSection', () => { it('returns home section entries (section hubs)', () => { const homeEntries = entriesForSection('home'); const ids = homeEntries.map(e => e.id); - // Non-hidden home entries include the main section hubs. + // Surviving home hub entries after the two-pane restructure. expect(ids).toContain('account'); - expect(ids).toContain('ai'); - expect(ids).toContain('agents-settings'); - expect(ids).toContain('features'); - expect(ids).toContain('composio'); - expect(ids).toContain('notifications-hub'); - expect(ids).toContain('crypto'); + expect(ids).toContain('appearance'); + expect(ids).toContain('personality'); + expect(ids).toContain('automations'); + expect(ids).toContain('integrations'); expect(ids).toContain('about'); - // billing is hiddenDeepLink so should be excluded. - expect(ids).not.toContain('billing'); + // The old ai / agents-settings / features / notifications-hub hub pages + // were retired — their slugs now redirect to leaf panels. + expect(ids).not.toContain('ai'); + expect(ids).not.toContain('agents-settings'); + expect(ids).not.toContain('features'); + expect(ids).not.toContain('notifications-hub'); }); it('returns empty array for a section that has no non-hidden entries', () => { @@ -177,12 +189,13 @@ describe('SETTINGS_ROUTE_REGISTRY integrity', () => { }); }); - it('contains the 5 newly-surfaced section hubs on home', () => { + it('surfaces the restructured home hub entries', () => { const homeIds = entriesForSection('home').map(e => e.id); - expect(homeIds).toContain('ai'); - expect(homeIds).toContain('agents-settings'); - expect(homeIds).toContain('features'); - expect(homeIds).toContain('composio'); - expect(homeIds).toContain('crypto'); + expect(homeIds).toContain('integrations'); + expect(homeIds).toContain('personality'); + expect(homeIds).toContain('automations'); + expect(homeIds).toContain('memory-sync'); + // billing is surfaced in the General group now (no longer a hidden deep-link). + expect(homeIds).toContain('billing'); }); }); diff --git a/app/src/components/settings/components/SettingsHeader.tsx b/app/src/components/settings/components/SettingsHeader.tsx index a64984a27..df1995f0a 100644 --- a/app/src/components/settings/components/SettingsHeader.tsx +++ b/app/src/components/settings/components/SettingsHeader.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from 'react'; +import { useInRouterContext, useLocation } from 'react-router-dom'; import { useT } from '../../../lib/i18n/I18nContext'; +import { useSettingsLayout } from '../layout/SettingsLayoutContext'; interface BreadcrumbItem { label: string; @@ -12,6 +14,11 @@ interface SettingsHeaderProps { title?: string; showBackButton?: boolean; onBack?: () => void; + /** + * Accepted for backward compatibility but no longer rendered — the two-pane + * sidebar replaced breadcrumb navigation. Call sites are cleaned up + * incrementally. + */ breadcrumbs?: BreadcrumbItem[]; /** * Optional right-aligned action (e.g. a refresh or pair-device button). @@ -22,26 +29,65 @@ interface SettingsHeaderProps { action?: ReactNode; } -const SettingsHeader = ({ +/** + * Resolve the current pathname without throwing when the header is rendered + * outside a `` (e.g. isolated settings-panel unit tests). Inside the + * app the header always sits within the router, so this returns the real path; + * with no router it falls back to '' which callers treat as a top-level route. + * + * Split into its own component so `useLocation` is only ever called when a + * router is actually present — keeping the rules-of-hooks contract intact. + */ +const SettingsHeader = (props: SettingsHeaderProps) => { + const inRouter = useInRouterContext(); + return inRouter ? ( + + ) : ( + + ); +}; + +const RoutedSettingsHeader = (props: SettingsHeaderProps) => { + const { pathname } = useLocation(); + return ; +}; + +const SettingsHeaderView = ({ className = '', title, showBackButton = false, onBack, - breadcrumbs, action, -}: SettingsHeaderProps) => { + pathname, +}: SettingsHeaderProps & { pathname: string }) => { const { t } = useT(); + const { inTwoPaneShell } = useSettingsLayout(); + + // These panels are also embedded outside /settings — e.g. Brain + // (`/brain?tab=memory-data`) and Connections (`/connections?tab=llm`). There + // the host page's own sidebar owns navigation, and the panel's `onBack` + // (sourced from useSettingsNavigation, which has no settings slug on those + // routes) would navigate away from the host. Suppress the back button when + // embedded outside the settings route tree. + const isSettingsPath = pathname.startsWith('/settings'); + const showBack = showBackButton && !!onBack && (isSettingsPath || !inTwoPaneShell); + + // Inside the settings two-pane shell, top-level destinations (/settings/) + // hide the back button on wide viewports — the sidebar provides navigation. + // Nested pages (team/manage/:id, agents/edit/:id, …) keep it at all widths. + const isTopLevel = pathname.split('/').filter(Boolean).length <= 2; + const backButtonClass = + inTwoPaneShell && isTopLevel + ? 'md:hidden w-6 h-6 flex items-center justify-center rounded-full hover:bg-stone-100 dark:bg-neutral-800 dark:hover:bg-neutral-800 transition-colors mr-2' + : 'w-6 h-6 flex items-center justify-center rounded-full hover:bg-stone-100 dark:bg-neutral-800 dark:hover:bg-neutral-800 transition-colors mr-2'; return (
{/* Back button */} - {showBackButton && onBack && ( - - ) : ( - - {crumb.label} - - )} - - - ))} - - - )} - {/* Title */} -

+

{title ?? t('nav.settings')}

diff --git a/app/src/components/settings/hooks/__tests__/useSettingsNavigation.coverage.test.tsx b/app/src/components/settings/hooks/__tests__/useSettingsNavigation.coverage.test.tsx index 0f28bcd8a..44d5e2db9 100644 --- a/app/src/components/settings/hooks/__tests__/useSettingsNavigation.coverage.test.tsx +++ b/app/src/components/settings/hooks/__tests__/useSettingsNavigation.coverage.test.tsx @@ -1,11 +1,15 @@ /** * Coverage-focused tests for useSettingsNavigation. * - * These tests supplement the existing breadcrumb tests with: + * The two-pane settings restructure retired breadcrumb navigation (the + * `breadcrumbs` field is now always empty — the sidebar replaced the trail) and + * collapsed the old section-hub pages (`ai`, `agents-settings`, `features`, + * `notifications-hub`, `crypto`) into leaf panels reachable from the sidebar. + * These tests now cover: * - Exact-match route resolution (no substring collisions). - * - Canonical breadcrumbs for a representative route in each section. - * - Composio section page and leaf breadcrumbs. - * - Verification that the removed 'messaging' route returns 'home'. + * - Leaf routes resolving to their own registry id. + * - Retired hub slugs and unknown slugs resolving to 'home'. + * - Breadcrumbs always being empty. */ import { screen } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; @@ -24,212 +28,106 @@ const NavigationProbe = () => { ); }; +const expectRoute = (path: string, route: string) => { + renderWithProviders(, { initialEntries: [path] }); + expect(screen.getByTestId('current-route')).toHaveTextContent(route); + // Breadcrumbs are retired across the board — always empty. + expect(screen.getByTestId('breadcrumbs')).toHaveTextContent(''); +}; + // --------------------------------------------------------------------------- -// Section: home (no breadcrumbs at root) +// home root // --------------------------------------------------------------------------- describe('home route', () => { test('/settings resolves to home with empty breadcrumbs', () => { - renderWithProviders(, { initialEntries: ['/settings'] }); - expect(screen.getByTestId('current-route')).toHaveTextContent('home'); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent(''); + expectRoute('/settings', 'home'); }); }); // --------------------------------------------------------------------------- -// Section: account — representative leaf +// Leaf routes resolve to their own registry id (representative per section) // --------------------------------------------------------------------------- -describe('account section', () => { - test('privacy returns Settings > Account breadcrumb', () => { - renderWithProviders(, { initialEntries: ['/settings/privacy'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Account'); - }); +describe('account section leaves', () => { + test('privacy resolves to privacy', () => expectRoute('/settings/privacy', 'privacy')); + test('security resolves to security', () => expectRoute('/settings/security', 'security')); + test('team resolves to team', () => expectRoute('/settings/team', 'team')); +}); - test('security returns Settings > Account breadcrumb', () => { - renderWithProviders(, { initialEntries: ['/settings/security'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Account'); - }); +describe('ai section leaves', () => { + test('llm resolves to llm', () => expectRoute('/settings/llm', 'llm')); + test('voice resolves to voice', () => expectRoute('/settings/voice', 'voice')); +}); - test('team returns Settings > Account breadcrumb', () => { - renderWithProviders(, { initialEntries: ['/settings/team'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Account'); - }); +describe('agents section leaves', () => { + test('agent-access resolves to agent-access', () => + expectRoute('/settings/agent-access', 'agent-access')); +}); + +describe('features section leaves', () => { + test('tools resolves to tools', () => expectRoute('/settings/tools', 'tools')); + test('companion resolves to companion', () => expectRoute('/settings/companion', 'companion')); +}); + +describe('integrations', () => { + test('integrations resolves to integrations', () => + expectRoute('/settings/integrations', 'integrations')); +}); + +describe('notifications', () => { + test('notifications resolves to notifications', () => + expectRoute('/settings/notifications', 'notifications')); +}); + +describe('crypto section leaves', () => { + test('recovery-phrase resolves to recovery-phrase', () => + expectRoute('/settings/recovery-phrase', 'recovery-phrase')); + test('wallet-balances resolves to wallet-balances', () => + expectRoute('/settings/wallet-balances', 'wallet-balances')); +}); + +describe('developer section leaves', () => { + test('cron-jobs resolves to cron-jobs', () => expectRoute('/settings/cron-jobs', 'cron-jobs')); + test('intelligence resolves to intelligence', () => + expectRoute('/settings/intelligence', 'intelligence')); + test('developer-options resolves to developer-options', () => + expectRoute('/settings/developer-options', 'developer-options')); }); // --------------------------------------------------------------------------- -// Section: ai — representative leaf +// Retired hub slugs and unknown slugs resolve to home // --------------------------------------------------------------------------- -describe('ai section', () => { - test('llm returns Settings > AI & Models breadcrumb', () => { - renderWithProviders(, { initialEntries: ['/settings/llm'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > AI & Models'); - }); - - test('voice returns Settings > AI & Models breadcrumb', () => { - renderWithProviders(, { initialEntries: ['/settings/voice'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > AI & Models'); - }); - - // Exact-match check: /settings/ai must not substring-match into a longer route. - test('/settings/ai resolves to section page "ai" (not a deeper route)', () => { - renderWithProviders(, { initialEntries: ['/settings/ai'] }); - expect(screen.getByTestId('current-route')).toHaveTextContent('ai'); - // ai is a home-level section hub — breadcrumb is just Settings. - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings'); - }); +describe('retired hub slugs resolve to home', () => { + test('ai (retired hub) resolves to home', () => expectRoute('/settings/ai', 'home')); + test('agents-settings (retired hub) resolves to home', () => + expectRoute('/settings/agents-settings', 'home')); + test('features (retired hub) resolves to home', () => expectRoute('/settings/features', 'home')); + test('notifications-hub (retired hub) resolves to home', () => + expectRoute('/settings/notifications-hub', 'home')); + test('crypto (retired hub) resolves to home', () => expectRoute('/settings/crypto', 'home')); }); -// --------------------------------------------------------------------------- -// Section: agents — representative leaf -// --------------------------------------------------------------------------- - -describe('agents section', () => { - test('autonomy returns Settings > Agents breadcrumb', () => { - renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Agents'); - }); - - test('agent-access returns Settings > Agents breadcrumb', () => { - renderWithProviders(, { initialEntries: ['/settings/agent-access'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Agents'); - }); - - test('agents-settings section page returns Settings only', () => { - renderWithProviders(, { initialEntries: ['/settings/agents-settings'] }); - expect(screen.getByTestId('current-route')).toHaveTextContent('agents-settings'); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings'); - }); -}); - -// --------------------------------------------------------------------------- -// Section: features — representative leaf -// --------------------------------------------------------------------------- - -describe('features section', () => { - test('tools returns Settings > Features breadcrumb', () => { - renderWithProviders(, { initialEntries: ['/settings/tools'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Features'); - }); - - test('companion returns Settings > Features breadcrumb', () => { - renderWithProviders(, { initialEntries: ['/settings/companion'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Features'); - }); -}); - -// --------------------------------------------------------------------------- -// Section: composio — section page + leaf -// --------------------------------------------------------------------------- - -describe('composio section', () => { - test('composio section page returns Settings only', () => { - renderWithProviders(, { initialEntries: ['/settings/composio'] }); - expect(screen.getByTestId('current-route')).toHaveTextContent('composio'); - // composio is a home-level section hub — breadcrumb is just Settings. - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings'); - }); - - test('task-sources returns Settings > Integrations breadcrumb', () => { - renderWithProviders(, { initialEntries: ['/settings/task-sources'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Integrations'); - }); - - test('webhooks-triggers returns Settings > Integrations breadcrumb', () => { - renderWithProviders(, { initialEntries: ['/settings/webhooks-triggers'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Integrations'); - }); -}); - -// --------------------------------------------------------------------------- -// Section: notifications — section page + leaf -// --------------------------------------------------------------------------- - -describe('notifications section', () => { - test('notifications-hub section page returns Settings only', () => { - renderWithProviders(, { initialEntries: ['/settings/notifications-hub'] }); - expect(screen.getByTestId('current-route')).toHaveTextContent('notifications-hub'); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings'); - }); - - test('notifications leaf returns Settings > Notifications', () => { - renderWithProviders(, { initialEntries: ['/settings/notifications'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Notifications'); - }); -}); - -// --------------------------------------------------------------------------- -// Section: crypto — section page + leaf -// --------------------------------------------------------------------------- - -describe('crypto section', () => { - test('crypto section page returns Settings only', () => { - renderWithProviders(, { initialEntries: ['/settings/crypto'] }); - expect(screen.getByTestId('current-route')).toHaveTextContent('crypto'); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings'); - }); - - test('recovery-phrase returns Settings > Crypto', () => { - renderWithProviders(, { initialEntries: ['/settings/recovery-phrase'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Crypto'); - }); - - test('wallet-balances returns Settings > Crypto', () => { - renderWithProviders(, { initialEntries: ['/settings/wallet-balances'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Crypto'); - }); -}); - -// --------------------------------------------------------------------------- -// Section: developer — representative leaf -// --------------------------------------------------------------------------- - -describe('developer section', () => { - test('cron-jobs returns Settings > Developer Options', () => { - renderWithProviders(, { initialEntries: ['/settings/cron-jobs'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Developer Options'); - }); - - test('intelligence returns Settings > Developer Options', () => { - renderWithProviders(, { initialEntries: ['/settings/intelligence'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Developer Options'); - }); - - test('developer-options section page returns Settings only', () => { - renderWithProviders(, { initialEntries: ['/settings/developer-options'] }); - expect(screen.getByTestId('current-route')).toHaveTextContent('developer-options'); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings'); - }); -}); - -// --------------------------------------------------------------------------- -// Removed routes / unknown slugs -// --------------------------------------------------------------------------- - describe('unknown / removed routes', () => { - test('"messaging" route (removed) resolves to home', () => { - renderWithProviders(, { initialEntries: ['/settings/messaging'] }); - expect(screen.getByTestId('current-route')).toHaveTextContent('home'); - }); - - test('completely unknown slug resolves to home', () => { - renderWithProviders(, { initialEntries: ['/settings/not-a-real-route'] }); - expect(screen.getByTestId('current-route')).toHaveTextContent('home'); - }); + test('"messaging" route (removed) resolves to home', () => + expectRoute('/settings/messaging', 'home')); + test('completely unknown slug resolves to home', () => + expectRoute('/settings/not-a-real-route', 'home')); }); // --------------------------------------------------------------------------- -// Exact-match: no substring collision between /settings/ai and longer paths +// Exact-match: no substring collision between "voice" and "voice-debug" // --------------------------------------------------------------------------- describe('no substring collision', () => { - test('/settings/ai does not match /settings/agent-access or /settings/agents', () => { - // Verifies that exact first-segment extraction prevents "ai" from matching - // routes whose slugs merely contain "ai" as a substring. - renderWithProviders(, { initialEntries: ['/settings/ai'] }); - expect(screen.getByTestId('current-route')).toHaveTextContent('ai'); - // Must not bleed into the agents section. - expect(screen.getByTestId('breadcrumbs')).not.toHaveTextContent('Agents'); + test('/settings/voice resolves to voice, not voice-debug', () => { + // Exact first-segment extraction prevents "voice" from matching the longer + // "voice-debug" developer route (or vice-versa). + expectRoute('/settings/voice', 'voice'); + }); + + test('/settings/voice-debug resolves to voice-debug', () => { + expectRoute('/settings/voice-debug', 'voice-debug'); }); }); diff --git a/app/src/components/settings/hooks/__tests__/useSettingsNavigation.test.tsx b/app/src/components/settings/hooks/__tests__/useSettingsNavigation.test.tsx index 2e92241df..973c1cda0 100644 --- a/app/src/components/settings/hooks/__tests__/useSettingsNavigation.test.tsx +++ b/app/src/components/settings/hooks/__tests__/useSettingsNavigation.test.tsx @@ -10,53 +10,26 @@ const BreadcrumbProbe = () => { return
{breadcrumbs.map(b => b.label).join(' > ')}
; }; -describe('useSettingsNavigation breadcrumbs', () => { - test('notification-routing returns Settings > Developer Options', () => { - renderWithProviders(, { - initialEntries: ['/settings/notification-routing'], - }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Developer Options'); - }); +/** + * The two-pane settings restructure retired breadcrumb navigation — the sidebar + * replaced the trail, so `breadcrumbs` is now always empty regardless of route. + * The field is retained (always []) so the many panel call sites keep compiling. + * Route resolution itself is covered in useSettingsNavigation.coverage.test.tsx. + */ +describe('useSettingsNavigation breadcrumbs (retired — always empty)', () => { + const routes = [ + '/settings', + '/settings/notifications', + '/settings/tasks', + '/settings/developer-options', + '/settings/personality', + '/settings/recovery-phrase', + '/settings/wallet-balances', + '/settings/notification-routing', + ]; - test('notifications-hub returns Settings (section page)', () => { - // notifications-hub is now a home-level section hub — its breadcrumb is just Settings. - renderWithProviders(, { initialEntries: ['/settings/notifications-hub'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings'); - }); - - test('notifications panel nests under Settings > Notifications', () => { - // notifications is a leaf under the notifications section — breadcrumb is Settings > Notifications. - renderWithProviders(, { initialEntries: ['/settings/notifications'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Notifications'); - }); - - test('tasks returns Settings > Developer Options', () => { - renderWithProviders(, { initialEntries: ['/settings/tasks'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Developer Options'); - }); - - test('developer-options returns Settings (section page)', () => { - renderWithProviders(, { initialEntries: ['/settings/developer-options'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings'); - }); - - test('persona returns Settings (top-level)', () => { - renderWithProviders(, { initialEntries: ['/settings/persona'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings'); - }); - - test('crypto returns Settings (section page)', () => { - renderWithProviders(, { initialEntries: ['/settings/crypto'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings'); - }); - - test('recovery-phrase returns Settings > Crypto', () => { - renderWithProviders(, { initialEntries: ['/settings/recovery-phrase'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Crypto'); - }); - - test('wallet-balances returns Settings > Crypto', () => { - renderWithProviders(, { initialEntries: ['/settings/wallet-balances'] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Crypto'); + test.each(routes)('breadcrumbs are empty for %s', route => { + renderWithProviders(, { initialEntries: [route] }); + expect(screen.getByTestId('breadcrumbs')).toHaveTextContent(''); }); }); diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts index 189983604..f9dce3500 100644 --- a/app/src/components/settings/hooks/useSettingsNavigation.ts +++ b/app/src/components/settings/hooks/useSettingsNavigation.ts @@ -1,17 +1,11 @@ -// [settings] navigation hook — route resolution and breadcrumb derivation. -// Uses the settingsRouteRegistry as the single source of truth so that every -// registered route automatically yields a correct breadcrumb trail without -// maintaining a parallel switch-statement. +// [settings] navigation hook — route resolution for the two-pane settings +// layout. Uses the settingsRouteRegistry as the single source of truth so +// 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 { - entryRoute, - findEntryByRoute, - SETTINGS_ROUTE_REGISTRY, - type SettingsSection, -} from '../settingsRouteRegistry'; +import { entryRoute, findEntryByRoute, SETTINGS_ROUTE_REGISTRY } from '../settingsRouteRegistry'; const log = debug('settings:nav'); @@ -22,10 +16,8 @@ const log = debug('settings:nav'); export type SettingsRoute = | 'home' | 'agents' - | 'agents-settings' | 'agent-access' | 'account' - | 'features' | 'cron-jobs' | 'screen-intelligence' | 'autocomplete' @@ -35,15 +27,12 @@ export type SettingsRoute = | 'team-members' | 'team-invites' | 'developer-options' - | 'autonomy' - | 'ai' | 'llm' | 'voice' | 'tools' | 'memory-data' | 'memory-sync' | 'memory-debug' - | 'crypto' | 'recovery-phrase' | 'wallet-balances' | 'webhooks-debug' @@ -53,18 +42,13 @@ export type SettingsRoute = | 'voice-debug' | 'local-model-debug' | 'notifications' - | 'notifications-hub' | 'notification-routing' - | 'mascot' - | 'persona' + | 'personality' | 'appearance' | 'approval-history' | 'intelligence' - | 'webhooks-triggers' + | 'integrations' | 'composio-triggers' - | 'composio-routing' - | 'composio' - | 'task-sources' | 'tasks' | 'mcp-server' | 'dev-workflow' @@ -72,13 +56,11 @@ export type SettingsRoute = | 'permissions' | 'activity-level' | 'devices' - | 'heartbeat' + | 'usage' | 'security' | 'migration' | 'companion' | 'embeddings' - | 'ledger-usage' - | 'cost-dashboard' | 'search' | 'skills-runner' | 'event-log' @@ -159,24 +141,6 @@ const getCurrentRoute = (pathname: string): SettingsRoute => { return 'home'; }; -// --------------------------------------------------------------------------- -// Section → breadcrumb label mapping (static, no i18n hook dependency). -// Breadcrumb labels are intentionally English-only for now (the existing -// implementation was also English). A future pass can thread the translator. -// --------------------------------------------------------------------------- - -const SECTION_LABEL: Record = { - home: 'Settings', - account: 'Account', - ai: 'AI & Models', - agents: 'Agents', - features: 'Features', - composio: 'Integrations', - crypto: 'Crypto', - notifications: 'Notifications', - developer: 'Developer Options', -}; - export const useSettingsNavigation = (): SettingsNavigationHook => { const navigate = useNavigate(); const location = useLocation(); @@ -228,78 +192,12 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { // ------------------------------------------------------------------------- // Breadcrumbs — derived from the registry. // - // The root crumb is always "Settings" (pointing to /settings). - // Section pages (section === 'home') trail: [Settings]. - // Leaf panels trail: [Settings] > [Section label]. - // Special multi-level trails (team sub-pages, approval-history) are handled - // explicitly below. + // Breadcrumbs were replaced by the two-pane sidebar — the trail is no longer + // rendered anywhere. The field is kept (always empty) so the ~50 panel call + // sites keep compiling until the prop is mechanically removed. // ------------------------------------------------------------------------- - const settingsCrumb: BreadcrumbItem = { label: 'Settings', onClick: () => navigate('/settings') }; - - const getBreadcrumbs = (): BreadcrumbItem[] => { - if (currentRoute === 'home') return []; - - // Special cases with deeper trails. - if (currentRoute === 'team-members' || currentRoute === 'team-invites') { - return [ - settingsCrumb, - { label: SECTION_LABEL.account, onClick: () => navigate('/settings/account') }, - { label: 'Team', onClick: () => navigate('/settings/team') }, - ]; - } - - if (currentRoute === 'approval-history') { - return [ - settingsCrumb, - { label: SECTION_LABEL.agents, onClick: () => navigate('/settings/agents-settings') }, - { label: 'Agent access', onClick: () => navigate('/settings/agent-access') }, - ]; - } - - // Notification preferences panel nests under notifications-hub. - if (currentRoute === 'notifications') { - return [ - settingsCrumb, - { - label: SECTION_LABEL.notifications, - onClick: () => navigate('/settings/notifications-hub'), - }, - ]; - } - - // Legacy redirect target — kept working but mapped to developer. - if (currentRoute === 'notification-routing') { - return [ - settingsCrumb, - { label: SECTION_LABEL.developer, onClick: () => navigate('/settings/developer-options') }, - ]; - } - - // Look up the entry in the registry using the current route. - // The currentRoute is the entry id; try by id first, then by resolved route. - const entry = - SETTINGS_ROUTE_REGISTRY.find(e => e.id === currentRoute) ?? - SETTINGS_ROUTE_REGISTRY.find(e => entryRoute(e) === currentRoute); - - if (!entry) { - log('breadcrumbs: no registry entry for "%s"', currentRoute); - return [settingsCrumb]; - } - - // Home-level entries (section === 'home') are top-level section pages. - if (entry.section === 'home') { - return [settingsCrumb]; - } - - // Leaf panels: Settings →
. - const sectionLabel = SECTION_LABEL[entry.section]; - const sectionRoute = sectionRouteForSection(entry.section); - - return [settingsCrumb, { label: sectionLabel, onClick: () => navigate(sectionRoute) }]; - }; - - const breadcrumbs = getBreadcrumbs(); + const breadcrumbs: BreadcrumbItem[] = []; return { currentRoute, @@ -310,30 +208,3 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { breadcrumbs, }; }; - -// --------------------------------------------------------------------------- -// Helper: canonical section-page route for a given section. -// --------------------------------------------------------------------------- - -const sectionRouteForSection = (section: SettingsSection): string => { - switch (section) { - case 'account': - return '/settings/account'; - case 'ai': - return '/settings/ai'; - case 'agents': - return '/settings/agents-settings'; - case 'features': - return '/settings/features'; - case 'composio': - return '/settings/composio'; - case 'crypto': - return '/settings/crypto'; - case 'notifications': - return '/settings/notifications-hub'; - case 'developer': - return '/settings/developer-options'; - case 'home': - return '/settings'; - } -}; diff --git a/app/src/components/settings/layout/SettingsIndexRedirect.tsx b/app/src/components/settings/layout/SettingsIndexRedirect.tsx new file mode 100644 index 000000000..0011d8a28 --- /dev/null +++ b/app/src/components/settings/layout/SettingsIndexRedirect.tsx @@ -0,0 +1,21 @@ +import { Navigate } from 'react-router-dom'; + +import { useMediaQuery } from '../../../hooks/useMediaQuery'; + +/** + * /settings index behavior: + * - wide (md+): redirect to the first sidebar destination so the content + * pane is never empty; + * - narrow: render nothing — the sidebar itself is the index page (the + * classic drill-down home list). + * + * The media query re-evaluates on resize, so widening a window parked at + * /settings auto-selects the first item. + */ +const SettingsIndexRedirect = () => { + const isWide = useMediaQuery('(min-width: 768px)'); + if (isWide) return ; + return null; +}; + +export default SettingsIndexRedirect; diff --git a/app/src/components/settings/layout/SettingsLayout.tsx b/app/src/components/settings/layout/SettingsLayout.tsx new file mode 100644 index 000000000..7bae35cad --- /dev/null +++ b/app/src/components/settings/layout/SettingsLayout.tsx @@ -0,0 +1,55 @@ +import debug from 'debug'; +import { Outlet } from 'react-router-dom'; + +import TwoPanelLayout from '../../layout/TwoPanelLayout'; +import { SettingsLayoutProvider } from './SettingsLayoutContext'; +import SettingsSidebar from './SettingsSidebar'; +import SettingsSubNav from './SettingsSubNav'; + +const log = debug('settings:layout'); + +/** + * Two-pane settings shell, built on the reusable {@link TwoPanelLayout}. + * + * The grouped navigation sidebar is always shown and the layout spans the full + * width of the page; the sidebar is resizable (drag the divider) and its width + * persists per user via the `layout` slice (id `settings`). Each pane scrolls + * independently, so the nav and the routed panel never fight over one + * scrollbar. + */ +const SettingsLayout = () => { + log('render'); + + return ( + + + +
+ }> +
+
+ +
+ +
+ + + ); +}; + +export default SettingsLayout; diff --git a/app/src/components/settings/layout/SettingsLayoutContext.tsx b/app/src/components/settings/layout/SettingsLayoutContext.tsx new file mode 100644 index 000000000..c68703b75 --- /dev/null +++ b/app/src/components/settings/layout/SettingsLayoutContext.tsx @@ -0,0 +1,17 @@ +import { createContext, useContext } from 'react'; + +/** + * Marks panels as being rendered inside the two-pane settings shell so shared + * chrome (SettingsHeader) can adapt: on wide viewports the sidebar provides + * navigation, so top-level panels hide their back button. + * + * Defaults to false so panels rendered outside the shell (tests, embedded + * uses) keep their standalone behavior. + */ +const SettingsLayoutContext = createContext<{ inTwoPaneShell: boolean }>({ inTwoPaneShell: false }); + +export const SettingsLayoutProvider = SettingsLayoutContext.Provider; + +export const useSettingsLayout = () => useContext(SettingsLayoutContext); + +export default SettingsLayoutContext; diff --git a/app/src/components/settings/layout/SettingsSidebar.tsx b/app/src/components/settings/layout/SettingsSidebar.tsx new file mode 100644 index 000000000..c1f5866fa --- /dev/null +++ b/app/src/components/settings/layout/SettingsSidebar.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { APP_VERSION } from '../../../utils/config'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +import SettingsSearchBar from '../search/SettingsSearchBar'; +import { useSettingsSearch } from '../search/useSettingsSearch'; +import { + entryRoute, + NAV_GROUP_LABEL_KEY, + resolveSidebarId, + sidebarGroups, +} from '../settingsRouteRegistry'; +import { SETTINGS_NAV_ICONS } from './settingsNavIcons'; + +/** A renderable nav row, normalised across grouped entries and search results. */ +interface NavRow { + id: string; + label: string; + route: string; + /** Accent the row (e.g. Billing) even when inactive. */ + highlight?: boolean; +} + +interface NavSection { + key: string; + /** i18n key for the group heading, or null to render no heading (search results). */ + labelKey: string | null; + rows: NavRow[]; +} + +/** + * Grouped settings navigation. On wide viewports this is the persistent left + * pane of the two-pane layout; on narrow viewports it doubles as the + * /settings index page (the old drill-down home list). + */ +const SettingsSidebar = () => { + const { t } = useT(); + const { currentRoute, navigateToSettings } = useSettingsNavigation(); + + // While searching we render a flat, ranked result list backed by the FULL + // route registry (via useSettingsSearch) — not just the top-level sidebar + // entries — so deep/sub-nav destinations (privacy, security, agent-access, …) + // remain reachable via search. With no query we render the grouped nav. + const [searchQuery, setSearchQuery] = useState(''); + const isSearching = searchQuery.trim().length > 0; + const searchResults = useSettingsSearch(searchQuery); + + const activeSidebarId = resolveSidebarId(currentRoute); + const sections: NavSection[] = isSearching + ? [ + { + key: 'results', + labelKey: null, + rows: searchResults.map(result => ({ + id: result.entry.id, + label: result.title, + route: result.entry.route, + })), + }, + ] + : sidebarGroups().map(group => ({ + key: group.group, + labelKey: NAV_GROUP_LABEL_KEY[group.group], + rows: group.entries.map(entry => ({ + id: entry.id, + label: t(entry.titleKey), + route: entryRoute(entry), + highlight: entry.highlight, + })), + })); + const hasRows = sections.some(section => section.rows.length > 0); + + return ( + + ); +}; + +export default SettingsSidebar; diff --git a/app/src/components/settings/layout/SettingsSubNav.tsx b/app/src/components/settings/layout/SettingsSubNav.tsx new file mode 100644 index 000000000..e47696e40 --- /dev/null +++ b/app/src/components/settings/layout/SettingsSubNav.tsx @@ -0,0 +1,47 @@ +import { useT } from '../../../lib/i18n/I18nContext'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +import { entryRoute, resolveSidebarId, subNavSiblings } from '../settingsRouteRegistry'; + +/** + * Pill-tab row of real route links shown above panels that belong to a + * sidebar family (e.g. Account → Team / Privacy / Security / Migration). + * Each pill navigates to its own route — no nested hub pages. + */ +const SettingsSubNav = () => { + const { t } = useT(); + const { currentRoute, navigateToSettings } = useSettingsNavigation(); + + const sidebarId = resolveSidebarId(currentRoute); + const siblings = sidebarId ? subNavSiblings(sidebarId) : []; + + if (siblings.length === 0) return null; + + return ( +
+ {siblings.map(entry => { + const active = entry.id === currentRoute; + return ( + + ); + })} +
+ ); +}; + +export default SettingsSubNav; diff --git a/app/src/components/settings/layout/settingsNavIcons.tsx b/app/src/components/settings/layout/settingsNavIcons.tsx new file mode 100644 index 000000000..3a78c32f1 --- /dev/null +++ b/app/src/components/settings/layout/settingsNavIcons.tsx @@ -0,0 +1,162 @@ +import { Fragment, type ReactNode } from 'react'; + +// --------------------------------------------------------------------------- +// Sidebar icons, keyed by settings registry entry id. Consolidates the SVGs +// previously duplicated across SettingsHome.tsx and Settings.tsx. +// --------------------------------------------------------------------------- + +const icon = (path: ReactNode) => ( + + {path} + +); + +const stroke = (d: string) => ( + +); + +export const SETTINGS_NAV_ICONS: Record = { + account: icon(stroke('M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z')), + appearance: icon(stroke('M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z')), + notifications: icon( + stroke( + 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' + ) + ), + llm: icon( + stroke( + 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z' + ) + ), + voice: icon( + stroke( + 'M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z' + ) + ), + personality: icon( + stroke( + 'M12 21a9 9 0 100-18 9 9 0 000 18zM9 10h.01M15 10h.01M9.5 15c.83.67 1.67 1 2.5 1s1.67-.33 2.5-1' + ) + ), + agents: icon( + stroke( + 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2z' + ) + ), + devices: icon( + stroke('M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z') + ), + 'memory-sync': icon( + stroke( + 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' + ) + ), + 'wallet-balances': icon( + stroke('M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z') + ), + integrations: icon(stroke('M13 10V3L4 14h7v7l9-11h-7z')), + 'screen-intelligence': icon(stroke('M3 5h18v12H3zM8 21h8m-4-4v4')), + tools: icon( + stroke( + 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065zM15 12a3 3 0 11-6 0 3 3 0 016 0z' + ) + ), + companion: icon( + stroke( + 'M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z' + ) + ), + 'developer-options': icon(stroke('M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4')), + about: icon(stroke('M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z')), + usage: icon( + stroke( + 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' + ) + ), + billing: icon( + stroke('M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z') + ), + automations: icon(stroke('M13 10V3L4 14h7v7l9-11h-7z')), + 'approval-history': icon( + stroke( + 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' + ) + ), + + // --- Developer & Diagnostics groups (paths mirror DeveloperOptionsPanel) --- + // Knowledge & Memory + intelligence: icon( + stroke( + 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z' + ) + ), + 'memory-data': icon( + stroke( + 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4' + ) + ), + 'memory-debug': icon(stroke('M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4')), + 'analysis-views': icon( + stroke( + 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' + ) + ), + // Agents & Autonomy + 'tool-policy-diagnostics': icon( + stroke( + 'M9 17v-5a2 2 0 012-2h2a2 2 0 012 2v5m-8 0h8m-8 0H7a2 2 0 01-2-2V7a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2' + ) + ), + 'agent-chat': icon( + stroke( + 'M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z' + ) + ), + 'local-model-debug': icon( + stroke( + 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z' + ) + ), + 'skills-runner': icon( + + {stroke( + 'M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z' + )} + {stroke('M21 12a9 9 0 11-18 0 9 9 0 0118 0z')} + + ), + // Models & Inference + 'model-health': icon( + stroke( + 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' + ) + ), + 'screen-awareness-debug': icon(stroke('M3 5h18v12H3zM8 21h8m-4-4v4')), + 'voice-debug': icon( + stroke( + 'M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z' + ) + ), + 'autocomplete-debug': icon(stroke('M4 6h16M4 10h10M4 14h7m3 4h3m0 0l-2-2m2 2l-2 2')), + // Automation & Integrations + tasks: icon( + stroke( + 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-6 0h.01M12 16h3m-6 0h.01' + ) + ), + 'cron-jobs': icon(stroke('M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z')), + 'composio-triggers': icon(stroke('M13 10V3L4 14h7v7l9-11h-7z')), + 'webhooks-debug': icon( + stroke( + 'M13.828 10.172a4 4 0 010 5.656l-2 2a4 4 0 01-5.656-5.656l1-1m5-5a4 4 0 015.656 5.656l-1 1m-5 5l5-5' + ) + ), + 'mcp-server': icon( + stroke('M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z') + ), + 'dev-workflow': icon(stroke('M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4')), + search: icon(stroke('M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z')), + // Diagnostics & Logs + 'event-log': icon(stroke('M4 6h16M4 10h16M4 14h16M4 18h16')), + 'build-info': icon(stroke('M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z')), +}; diff --git a/app/src/components/settings/panels/AIPanel.tsx b/app/src/components/settings/panels/AIPanel.tsx index 05eaa4f79..41abbd479 100644 --- a/app/src/components/settings/panels/AIPanel.tsx +++ b/app/src/components/settings/panels/AIPanel.tsx @@ -947,7 +947,7 @@ export type BackgroundLoopControlsView = 'all' | 'heartbeat' | 'ledger'; /** Minimal cloud-provider shape consumed by the loop map's `describeProvider` * helper — only slug/label/id are read. Accepting this narrower shape lets - * external panels (HeartbeatPanel, LedgerUsagePanel) feed in the API view + * external panels (UsagePanel) feed in the API view * (`CloudProviderView`) without copying the AIPanel-internal extras * (`authStyle`, `maskedKey`). */ export type BackgroundLoopProviderView = { id: string; slug: string; label: string }; diff --git a/app/src/components/settings/panels/AboutPanel.tsx b/app/src/components/settings/panels/AboutPanel.tsx index 0443c9796..c77620497 100644 --- a/app/src/components/settings/panels/AboutPanel.tsx +++ b/app/src/components/settings/panels/AboutPanel.tsx @@ -19,6 +19,7 @@ import Button from '../../ui/Button'; import SettingsHeader from '../components/SettingsHeader'; import { SettingsRow, SettingsSection } from '../controls'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +import SystemDiagnostics from './SystemDiagnostics'; const AboutPanel = () => { const { t } = useT(); @@ -169,6 +170,10 @@ const AboutPanel = () => {
+ + {/* Diagnostics (app logs, restart tour, staging Sentry test) — + relocated here from the retired Developer & Diagnostics page. */} +
); diff --git a/app/src/components/settings/panels/AccountPanel.tsx b/app/src/components/settings/panels/AccountPanel.tsx new file mode 100644 index 000000000..e07640c8a --- /dev/null +++ b/app/src/components/settings/panels/AccountPanel.tsx @@ -0,0 +1,60 @@ +import { useT } from '../../../lib/i18n/I18nContext'; +import { useCoreState } from '../../../providers/CoreStateProvider'; +import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +import LogoutAndClearActions from '../LogoutAndClearActions'; + +/** + * Account landing page for the two-pane settings layout. The old Account hub + * list (Team / Privacy / Security / Migration) is replaced by the sub-nav + * pills above the panel; this page keeps the signed-in summary and the + * destructive logout/clear actions. + */ +const AccountPanel = () => { + const { t } = useT(); + const { navigateBack, breadcrumbs } = useSettingsNavigation(); + const { snapshot } = useCoreState(); + + const user = snapshot.currentUser; + const name = user ? [user.firstName, user.lastName].filter(Boolean).join(' ') || null : null; + const username = user?.username ? `@${user.username}` : null; + + return ( +
+ + +
+ {(name || username) && ( +
+
+ {(name ?? username ?? '?').replace('@', '').slice(0, 1).toUpperCase()} +
+
+ {name && ( +
+ {name} +
+ )} + {username && ( +
+ {username} +
+ )} +
+
+ )} + +
+ +
+
+
+ ); +}; + +export default AccountPanel; diff --git a/app/src/components/settings/panels/AgentAccessPanel.tsx b/app/src/components/settings/panels/AgentAccessPanel.tsx index f9d651eb0..10b27afdb 100644 --- a/app/src/components/settings/panels/AgentAccessPanel.tsx +++ b/app/src/components/settings/panels/AgentAccessPanel.tsx @@ -26,6 +26,7 @@ import { SettingsTextField, } from '../controls'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +import AutonomyRateLimitSection from './AutonomyPanel'; // Installs are always *available* but never silent: every `install_tool` call // is routed through the approval gate, so the user is asked to Approve/Deny @@ -409,6 +410,9 @@ const AgentAccessPanel = () => { )} + {/* Action rate limit (formerly the standalone /settings/autonomy page) */} + + {/* Approval history */} { } /> + + {/* ── Display language (moved from the old settings home list) ── */} + + } + /> +
); diff --git a/app/src/components/settings/panels/AutonomyPanel.tsx b/app/src/components/settings/panels/AutonomyPanel.tsx index c5d7c6a14..14a06bebf 100644 --- a/app/src/components/settings/panels/AutonomyPanel.tsx +++ b/app/src/components/settings/panels/AutonomyPanel.tsx @@ -6,9 +6,7 @@ import { openhumanUpdateAutonomySettings, } from '../../../utils/tauriCommands/config'; import Button from '../../ui/Button'; -import SettingsHeader from '../components/SettingsHeader'; import { SettingsNumberField, SettingsRow, SettingsSection, SettingsStatusLine } from '../controls'; -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; // u32::MAX — the Rust default and our sentinel for "no limit". Inputs at or // above this value render as "Unlimited" and clamp to UNLIMITED on save. @@ -34,15 +32,15 @@ type Status = | { kind: 'error'; message: string }; /** - * Settings panel under Developer Options for editing the agent's - * max_actions_per_hour rate-limit. Loads the current value via - * openhumanGetAutonomySettings on mount; saving writes through + * Headerless section for editing the agent's max_actions_per_hour rate-limit, + * rendered inside AgentAccessPanel (formerly the standalone /settings/autonomy + * page — that slug now redirects to /settings/agent-access). Loads the current + * value via openhumanGetAutonomySettings on mount; saving writes through * openhumanUpdateAutonomySettings and persists to the user's config.toml. * New value applies to the next agent session. */ -const AutonomyPanel = () => { +const AutonomyRateLimitSection = () => { const { t } = useT(); - const { navigateBack, breadcrumbs } = useSettingsNavigation(); const [committed, setCommitted] = useState(null); const [draft, setDraft] = useState(''); const [status, setStatus] = useState({ kind: 'loading' }); @@ -101,86 +99,76 @@ const AutonomyPanel = () => { status.kind === 'error' ? `${t('autonomy.statusFailed')}: ${status.message}` : null; return ( -
- + +
+ { + setDraft(v); + if (status.kind === 'saved' || status.kind === 'error') { + setStatus({ kind: 'idle' }); + } + }} + onCommit={() => {}} + unit={t('autonomy.maxActionsLabel')} + min={MIN} + max={MAX} + disabled={status.kind === 'loading' || status.kind === 'saving'} + invalid={!isValid && trimmed !== ''} + aria-label={t('autonomy.maxActionsLabel')} + /> + +
+ +
+ {PRESETS.map(p => ( + + ))} +
+ + {!isValid && trimmed !== '' && ( +

+ {t('autonomy.invalidIntegerMsg')} +

+ )} + {isValid && parsed === UNLIMITED && ( +

+ {t('autonomy.unlimitedNote')} +

+ )} + + +
+ } /> -
- - -
- { - setDraft(v); - if (status.kind === 'saved' || status.kind === 'error') { - setStatus({ kind: 'idle' }); - } - }} - onCommit={() => {}} - unit={t('autonomy.maxActionsLabel')} - min={MIN} - max={MAX} - disabled={status.kind === 'loading' || status.kind === 'saving'} - invalid={!isValid && trimmed !== ''} - aria-label={t('autonomy.maxActionsLabel')} - /> - -
- -
- {PRESETS.map(p => ( - - ))} -
- - {!isValid && trimmed !== '' && ( -

- {t('autonomy.invalidIntegerMsg')} -

- )} - {isValid && parsed === UNLIMITED && ( -

- {t('autonomy.unlimitedNote')} -

- )} - - -
- } - /> - - - + ); }; -export default AutonomyPanel; +export default AutonomyRateLimitSection; diff --git a/app/src/components/settings/panels/BillingPanel.test.tsx b/app/src/components/settings/panels/BillingPanel.test.tsx index 983450bf1..bd7dc1c6c 100644 --- a/app/src/components/settings/panels/BillingPanel.test.tsx +++ b/app/src/components/settings/panels/BillingPanel.test.tsx @@ -27,53 +27,34 @@ describe('', () => { openUrlMock.mockResolvedValue(undefined); }); - it('opens the billing dashboard on mount and shows the post-open status', async () => { + it('renders the "billing moved to web" view without auto-opening the browser', async () => { const Panel = await importPanel(); render(); - // The auto-open effect fires once; while the promise is pending the - // panel reports the "opening" status. Once it resolves we settle on - // the "idle" copy that tells users the browser should be open. - await waitFor(() => { - expect(openUrlMock).toHaveBeenCalledWith('https://tinyhumans.ai/dashboard'); - }); - await waitFor(() => { - expect( - screen.getByText('If your browser did not open, use the button above.') - ).toBeInTheDocument(); - }); + // Billing no longer auto-opens the dashboard on mount (auto-open removed): + // the panel just explains billing moved to the web. + expect( + screen.getByText( + /Subscription changes, payment methods, credits, and invoices are now managed/ + ) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Open billing dashboard' })).toBeInTheDocument(); + // No openUrl call happens on mount. + expect(openUrlMock).not.toHaveBeenCalled(); }); - it('surfaces an error message when openUrl rejects on mount', async () => { - openUrlMock.mockRejectedValueOnce(new Error('boom')); + it('opens the dashboard when the user clicks the primary button', async () => { const Panel = await importPanel(); render(); - // First call is the auto-open attempt that rejects -> error copy renders. - await waitFor(() => { - expect( - screen.getByText('The browser could not be opened automatically. Use the button above.') - ).toBeInTheDocument(); - }); - }); - - it('re-opens the dashboard when the user clicks the primary button', async () => { - const Panel = await importPanel(); - render(); - - // Wait for the mount effect call to complete first so the second call - // is unambiguously the click. - await waitFor(() => expect(openUrlMock).toHaveBeenCalledTimes(1)); - fireEvent.click(screen.getByRole('button', { name: 'Open billing dashboard' })); - await waitFor(() => expect(openUrlMock).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(openUrlMock).toHaveBeenCalledTimes(1)); expect(openUrlMock).toHaveBeenLastCalledWith('https://tinyhumans.ai/dashboard'); }); it('invokes the navigation back handler from both the header and the inline button', async () => { const Panel = await importPanel(); render(); - await waitFor(() => expect(openUrlMock).toHaveBeenCalledTimes(1)); // The SettingsHeader back button (aria-label "Back") and the inline // "Back to settings" button both route through navigateBack. diff --git a/app/src/components/settings/panels/BillingPanel.tsx b/app/src/components/settings/panels/BillingPanel.tsx index 7ed8cb901..fbf219085 100644 --- a/app/src/components/settings/panels/BillingPanel.tsx +++ b/app/src/components/settings/panels/BillingPanel.tsx @@ -1,6 +1,3 @@ -import createDebug from 'debug'; -import { useEffect, useState } from 'react'; - import { useT } from '../../../lib/i18n/I18nContext'; import { BILLING_DASHBOARD_URL } from '../../../utils/links'; import { openUrl } from '../../../utils/openUrl'; @@ -8,37 +5,9 @@ import Button from '../../ui/Button'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -const log = createDebug('openhuman:billing-panel'); - const BillingPanel = () => { const { t } = useT(); const { navigateBack, breadcrumbs } = useSettingsNavigation(); - const [status, setStatus] = useState<'opening' | 'idle' | 'error'>('opening'); - - useEffect(() => { - let cancelled = false; - - const openDashboard = async () => { - log('[redirect] opening billing dashboard url=%s', BILLING_DASHBOARD_URL); - try { - await openUrl(BILLING_DASHBOARD_URL); - if (!cancelled) { - setStatus('idle'); - } - } catch (error) { - log('[redirect] failed to open billing dashboard: %O', error); - if (!cancelled) { - setStatus('error'); - } - } - }; - - void openDashboard(); - - return () => { - cancelled = true; - }; - }, []); return (
@@ -77,22 +46,6 @@ const BillingPanel = () => { {t('settings.billing.backToSettings')}
- - {status === 'opening' && ( -

- {t('settings.billing.openingBrowser')} -

- )} - {status === 'idle' && ( -

- {t('settings.billing.browserNotOpen')} -

- )} - {status === 'error' && ( -

- {t('settings.billing.browserOpenFailed')} -

- )} diff --git a/app/src/components/settings/panels/DevicesComingSoonPanel.tsx b/app/src/components/settings/panels/DevicesComingSoonPanel.tsx deleted file mode 100644 index a2f76fca9..000000000 --- a/app/src/components/settings/panels/DevicesComingSoonPanel.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useT } from '../../../lib/i18n/I18nContext'; -import EmptyStateCard from '../../EmptyStateCard'; -import SettingsHeader from '../components/SettingsHeader'; -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; - -const DevicesComingSoonPanel = () => { - const { t } = useT(); - const { navigateBack, breadcrumbs } = useSettingsNavigation(); - - return ( -
-
- 0} - onBack={navigateBack} - breadcrumbs={breadcrumbs} - /> -
- -
-
-
- ); -}; - -export default DevicesComingSoonPanel; diff --git a/app/src/components/settings/panels/DevicesPanel.tsx b/app/src/components/settings/panels/DevicesPanel.tsx index c489697b6..6c5fb0e9a 100644 --- a/app/src/components/settings/panels/DevicesPanel.tsx +++ b/app/src/components/settings/panels/DevicesPanel.tsx @@ -284,7 +284,7 @@ const DevicesPanel = () => {
0} + showBackButton onBack={navigateBack} breadcrumbs={breadcrumbs} action={ diff --git a/app/src/components/settings/panels/HeartbeatPanel.tsx b/app/src/components/settings/panels/HeartbeatPanel.tsx deleted file mode 100644 index 3490588a7..000000000 --- a/app/src/components/settings/panels/HeartbeatPanel.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { useT } from '../../../lib/i18n/I18nContext'; -import { type AISettings, loadAISettings } from '../../../services/api/aiSettingsApi'; -import SettingsHeader from '../components/SettingsHeader'; -import { SettingsStatusLine } from '../controls'; -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -import { BackgroundLoopControls } from './AIPanel'; - -const HeartbeatPanel = () => { - const { t } = useT(); - const { navigateBack, breadcrumbs } = useSettingsNavigation(); - const [snapshot, setSnapshot] = useState(null); - const [loadError, setLoadError] = useState(null); - - useEffect(() => { - let cancelled = false; - loadAISettings() - .then(s => { - if (!cancelled) setSnapshot(s); - }) - .catch(err => { - if (!cancelled) setLoadError(err instanceof Error ? err.message : String(err)); - }); - return () => { - cancelled = true; - }; - }, []); - - return ( -
- -
- - {snapshot ? ( - - ) : !loadError ? ( -
- {t('common.loading')} -
- ) : null} -
-
- ); -}; - -export default HeartbeatPanel; diff --git a/app/src/components/settings/panels/IntegrationsPanel.tsx b/app/src/components/settings/panels/IntegrationsPanel.tsx new file mode 100644 index 000000000..034609f3d --- /dev/null +++ b/app/src/components/settings/panels/IntegrationsPanel.tsx @@ -0,0 +1,95 @@ +import { useLocation, useNavigate } from 'react-router-dom'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import Webhooks from '../../../pages/Webhooks'; +import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +import ComposioPanel from './ComposioPanel'; +import TaskSourcesPanel from './TaskSourcesPanel'; + +type TabId = 'task-sources' | 'composio' | 'webhooks'; + +const TAB_HASH: Record = { + 'task-sources': '', + composio: '#composio', + webhooks: '#webhooks', +}; + +const hashToTab = (hash: string): TabId => { + if (hash === '#composio') return 'composio'; + if (hash === '#webhooks') return 'webhooks'; + return 'task-sources'; +}; + +/** + * Single Settings entry for integrations. Combines the task-source toggles + * (TaskSourcesPanel), the Composio routing/auth controls (ComposioPanel) and + * the webhook trigger history/triage (Webhooks page) as three tabs under one + * header. The active tab is reflected in the URL hash (`#composio`, + * `#webhooks`) so deep links and the legacy task-sources/composio-routing/ + * webhooks-triggers redirects land on the right view. + */ +const IntegrationsPanel = () => { + const { t } = useT(); + const { navigateBack, breadcrumbs } = useSettingsNavigation(); + const location = useLocation(); + const navigate = useNavigate(); + // The router is the single source of truth for the active tab. + const tab: TabId = hashToTab(location.hash); + + const selectTab = (next: TabId) => { + navigate(`${location.pathname}${TAB_HASH[next]}`, { replace: true }); + }; + + const tabs: { id: TabId; label: string }[] = [ + { id: 'task-sources', label: t('settings.taskSources.title') }, + { id: 'composio', label: t('settings.developerMenu.composioRouting.title') }, + { id: 'webhooks', label: t('settings.developerMenu.composeioTriggers.title') }, + ]; + + return ( +
+ + +
+ {tabs.map(({ id, label }) => { + const selected = tab === id; + return ( + + ); + })} +
+ + {tab === 'task-sources' && } + {tab === 'composio' && ( +
+ +
+ )} + {tab === 'webhooks' && } +
+ ); +}; + +export default IntegrationsPanel; diff --git a/app/src/components/settings/panels/LedgerUsagePanel.tsx b/app/src/components/settings/panels/LedgerUsagePanel.tsx deleted file mode 100644 index c15999929..000000000 --- a/app/src/components/settings/panels/LedgerUsagePanel.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { useT } from '../../../lib/i18n/I18nContext'; -import { type AISettings, loadAISettings } from '../../../services/api/aiSettingsApi'; -import SettingsHeader from '../components/SettingsHeader'; -import { SettingsStatusLine } from '../controls'; -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -import { BackgroundLoopControls } from './AIPanel'; - -const LedgerUsagePanel = () => { - const { t } = useT(); - const { navigateBack, breadcrumbs } = useSettingsNavigation(); - const [snapshot, setSnapshot] = useState(null); - const [loadError, setLoadError] = useState(null); - - useEffect(() => { - let cancelled = false; - loadAISettings() - .then(s => { - if (!cancelled) setSnapshot(s); - }) - .catch(err => { - if (!cancelled) setLoadError(err instanceof Error ? err.message : String(err)); - }); - return () => { - cancelled = true; - }; - }, []); - - return ( -
- -
- - {snapshot ? ( - - ) : !loadError ? ( -
- {t('common.loading')} -
- ) : null} -
-
- ); -}; - -export default LedgerUsagePanel; diff --git a/app/src/components/settings/panels/MascotPanel.tsx b/app/src/components/settings/panels/MascotPanel.tsx index 68441b5a5..0ed3c3fc3 100644 --- a/app/src/components/settings/panels/MascotPanel.tsx +++ b/app/src/components/settings/panels/MascotPanel.tsx @@ -60,7 +60,13 @@ const COLOR_OPTIONS: ColorOption[] = [ { id: 'custom', labelKey: 'settings.mascot.colorCustom' }, ]; -const MascotPanel = () => { +interface MascotPanelProps { + /** When true the panel is hosted inside another settings page (the + * Personality & Face tabs) — skip the standalone SettingsHeader chrome. */ + embedded?: boolean; +} + +const MascotPanel = ({ embedded = false }: MascotPanelProps) => { const { t, locale } = useT(); const { navigateBack, breadcrumbs } = useSettingsNavigation(); const dispatch = useAppDispatch(); @@ -311,12 +317,14 @@ const MascotPanel = () => { return (
- + {!embedded && ( + + )}
{/* ── Mascot preview (intentional bespoke visual) ───────────── */} diff --git a/app/src/components/settings/panels/PersonaPanel.test.tsx b/app/src/components/settings/panels/PersonaPanel.test.tsx index 2a278eba3..3f8661142 100644 --- a/app/src/components/settings/panels/PersonaPanel.test.tsx +++ b/app/src/components/settings/panels/PersonaPanel.test.tsx @@ -153,10 +153,10 @@ describe('PersonaPanel', () => { }); }); - it('navigates to mascot settings for avatar & voice', async () => { + it('navigates to the Face tab for avatar & voice', async () => { renderWithProviders(); await waitFor(() => expect(screen.getByTestId('persona-soul-editor')).toBeInTheDocument()); fireEvent.click(screen.getByTestId('persona-open-mascot')); - expect(mockNavigateToSettings).toHaveBeenCalledWith('mascot'); + expect(mockNavigateToSettings).toHaveBeenCalledWith('personality#face'); }); }); diff --git a/app/src/components/settings/panels/PersonaPanel.tsx b/app/src/components/settings/panels/PersonaPanel.tsx index 73db017a6..5f7f32366 100644 --- a/app/src/components/settings/panels/PersonaPanel.tsx +++ b/app/src/components/settings/panels/PersonaPanel.tsx @@ -24,7 +24,13 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; const log = debug('persona:panel'); -const PersonaPanel = () => { +interface PersonaPanelProps { + /** When true the panel is hosted inside another settings page (the + * Personality & Face tabs) — skip the standalone SettingsHeader chrome. */ + embedded?: boolean; +} + +const PersonaPanel = ({ embedded = false }: PersonaPanelProps) => { const { t } = useT(); const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation(); const dispatch = useAppDispatch(); @@ -130,12 +136,14 @@ const PersonaPanel = () => { return (
- + {!embedded && ( + + )}
{/* ── Identity ─────────────────────────────────────────────── */} @@ -257,7 +265,7 @@ const PersonaPanel = () => { + ); + })} +
+ + {tab === 'personality' ? : } +
+ ); +}; + +export default PersonalityPanel; diff --git a/app/src/components/settings/panels/SystemDiagnostics.tsx b/app/src/components/settings/panels/SystemDiagnostics.tsx new file mode 100644 index 000000000..260f58b29 --- /dev/null +++ b/app/src/components/settings/panels/SystemDiagnostics.tsx @@ -0,0 +1,185 @@ +// --------------------------------------------------------------------------- +// SystemDiagnostics +// +// Diagnostic callouts (app logs, Sentry test, restart tour) that used to live +// on the standalone Developer & Diagnostics page. Now that the dev pages are +// listed directly in the settings sidebar, these utility rows live on the +// About page instead. Self-contained so About can drop them in as one block. +// --------------------------------------------------------------------------- +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { triggerSentryTestEvent } from '../../../services/analytics'; +import { APP_ENVIRONMENT } from '../../../utils/config'; +// `safeInvoke` (aliased to `invoke`) turns the CEF synchronous IPC throw into a +// rejected Promise so the `.catch(...)` handlers see a normal failure. +import { safeInvoke as invoke, isTauri } from '../../../utils/tauriCommands/common'; +import { resetWalkthrough } from '../../walkthrough/AppWalkthrough'; + +const LogsFolderRow = () => { + const { t } = useT(); + const [path, setPath] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isTauri()) return; + invoke('logs_folder_path') + .then(p => setPath(p ?? null)) + .catch(err => setError(err instanceof Error ? err.message : String(err))); + }, []); + + const onClick = async () => { + setError(null); + try { + await invoke('reveal_logs_folder'); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + if (!isTauri()) return null; + + return ( +
+
+
+
+ {t('devOptions.appLogs')} +
+
+ {t('devOptions.appLogsDesc')} +
+ {path && ( +
+ {path} +
+ )} +
+ +
+ {error && ( +
+ {error} +
+ )} +
+ ); +}; + +type SentryTestStatus = + | { kind: 'idle' } + | { kind: 'sending' } + | { kind: 'sent'; eventId: string | undefined } + | { kind: 'error'; message: string }; + +const SentryTestRow = () => { + const { t } = useT(); + const [status, setStatus] = useState({ kind: 'idle' }); + + const onClick = async () => { + setStatus({ kind: 'sending' }); + try { + const eventId = await triggerSentryTestEvent(); + setStatus({ kind: 'sent', eventId }); + } catch (err) { + setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) }); + } + }; + + return ( +
+
+
+
+ {t('devOptions.triggerSentryTest')} +
+
+ {t('devOptions.triggerSentryTestDesc')} +
+
+ +
+
+ {status.kind === 'sent' && ( + + {t('devOptions.eventSent')}.{' '} + {status.eventId ? ( + id: {status.eventId} + ) : ( + {t('devOptions.sentryDisabled')} + )} + + )} + {status.kind === 'error' && ( + + {t('devOptions.failed')}: {status.message} + + )} +
+
+ ); +}; + +const RestartTourRow = () => { + const { t } = useT(); + const navigate = useNavigate(); + + const onClick = () => { + resetWalkthrough(); + navigate('/home'); + }; + + return ( +
+
+
+
+ {t('settings.restartTour')} +
+
+ {t('settings.restartTourDesc')} +
+
+ +
+
+ ); +}; + +/** App logs, optional staging Sentry test, and the restart-tour action. */ +const SystemDiagnostics = () => { + const { t } = useT(); + const showSentryTest = APP_ENVIRONMENT === 'staging'; + + return ( +
+

+ {t('devOptions.titleDiagnostics')} +

+
+ + {showSentryTest && } + +
+
+ ); +}; + +export default SystemDiagnostics; diff --git a/app/src/components/settings/panels/TaskSourcesPanel.tsx b/app/src/components/settings/panels/TaskSourcesPanel.tsx index 294200bb7..29e69acc8 100644 --- a/app/src/components/settings/panels/TaskSourcesPanel.tsx +++ b/app/src/components/settings/panels/TaskSourcesPanel.tsx @@ -103,7 +103,13 @@ function formatSyncNotice(outcomes: Array<{ fetched: number; routed: number; pru ); } -const TaskSourcesPanel = () => { +interface TaskSourcesPanelProps { + /** When true the panel is hosted inside another settings page (the + * Integrations tabs) — skip the standalone SettingsHeader chrome. */ + embedded?: boolean; +} + +const TaskSourcesPanel = ({ embedded = false }: TaskSourcesPanelProps) => { const { t } = useT(); const { navigateBack, breadcrumbs } = useSettingsNavigation(); @@ -327,12 +333,14 @@ const TaskSourcesPanel = () => { return (
- + {!embedded && ( + + )}
diff --git a/app/src/components/settings/panels/UsagePanel.tsx b/app/src/components/settings/panels/UsagePanel.tsx new file mode 100644 index 000000000..fee70f076 --- /dev/null +++ b/app/src/components/settings/panels/UsagePanel.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { type AISettings, loadAISettings } from '../../../services/api/aiSettingsApi'; +import CostDashboardPanel from '../../dashboard/CostDashboardPanel'; +import SettingsHeader from '../components/SettingsHeader'; +import { SettingsStatusLine } from '../controls'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +import { BackgroundLoopControls } from './AIPanel'; + +type TabId = 'costs' | 'background'; + +const TAB_HASH: Record = { costs: '', background: '#background' }; + +const hashToTab = (hash: string): TabId => (hash === '#background' ? 'background' : 'costs'); + +/** + * Single Settings entry for usage & limits. Combines the cost dashboard + * (charts, budgets, usage log) and the background-activity controls + * (heartbeat cadences + usage ledger, previously the separate Heartbeat and + * Usage-ledger pages) as two tabs under one header. The active tab is + * reflected in the URL hash (`#background`) so deep links and the legacy + * heartbeat/ledger-usage redirects land on the right view. + */ +const UsagePanel = () => { + const { t } = useT(); + const { navigateBack, breadcrumbs } = useSettingsNavigation(); + const location = useLocation(); + const navigate = useNavigate(); + // The router is the single source of truth for the active tab. + const tab: TabId = hashToTab(location.hash); + + const selectTab = (next: TabId) => { + navigate(`${location.pathname}${TAB_HASH[next]}`, { replace: true }); + }; + + const tabs: { id: TabId; label: string }[] = [ + { id: 'costs', label: t('settings.costDashboard.title') }, + { id: 'background', label: t('settings.heartbeat.title') }, + ]; + + return ( +
+ + +
+ {tabs.map(({ id, label }) => { + const selected = tab === id; + return ( + + ); + })} +
+ + {tab === 'costs' ? : } +
+ ); +}; + +/** + * Background-activity tab body. Fetches the AI settings snapshot (routing map + * + cloud providers) that BackgroundLoopControls needs — lazily, only when + * this tab is mounted, so the default Costs tab doesn't pay for it. + */ +const BackgroundActivityTab = () => { + const { t } = useT(); + const [snapshot, setSnapshot] = useState(null); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + let cancelled = false; + loadAISettings() + .then(s => { + if (!cancelled) setSnapshot(s); + }) + .catch(err => { + if (!cancelled) setLoadError(err instanceof Error ? err.message : String(err)); + }); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+ + {snapshot ? ( + + ) : !loadError ? ( +
{t('common.loading')}
+ ) : null} +
+ ); +}; + +export default UsagePanel; diff --git a/app/src/components/settings/panels/VoicePanel.tsx b/app/src/components/settings/panels/VoicePanel.tsx index 4fc295a96..89683ee9f 100644 --- a/app/src/components/settings/panels/VoicePanel.tsx +++ b/app/src/components/settings/panels/VoicePanel.tsx @@ -1283,7 +1283,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => { {t('voice.providers.mascotVoiceDescPrefix')}{' '} diff --git a/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx b/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx index 99843b40c..aaf157971 100644 --- a/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/AutonomyPanel.test.tsx @@ -7,9 +7,9 @@ import { openhumanGetAutonomySettings, openhumanUpdateAutonomySettings, } from '../../../../utils/tauriCommands/config'; -import AutonomyPanel from '../AutonomyPanel'; +import AutonomyRateLimitSection from '../AutonomyPanel'; -// AutonomyPanel only reads/writes `max_actions_per_hour`, but the settings RPC +// AutonomyRateLimitSection only reads/writes `max_actions_per_hour`, but the settings RPC // now returns the full access-mode block — build a complete value so the mocks // satisfy `AutonomySettings`. const autonomy = (max_actions_per_hour: number): AutonomySettings => ({ @@ -23,14 +23,6 @@ const autonomy = (max_actions_per_hour: number): AutonomySettings => ({ auto_approve: [], }); -vi.mock('../../hooks/useSettingsNavigation', () => ({ - useSettingsNavigation: () => ({ - navigateBack: vi.fn(), - navigateToSettings: vi.fn(), - breadcrumbs: [], - }), -})); - vi.mock('../../../../utils/tauriCommands/config', async () => { const actual = await vi.importActual( '../../../../utils/tauriCommands/config' @@ -45,7 +37,7 @@ vi.mock('../../../../utils/tauriCommands/config', async () => { const mockGet = vi.mocked(openhumanGetAutonomySettings); const mockUpdate = vi.mocked(openhumanUpdateAutonomySettings); -describe('AutonomyPanel', () => { +describe('AutonomyRateLimitSection', () => { beforeEach(() => { mockGet.mockReset(); mockUpdate.mockReset(); @@ -53,14 +45,18 @@ describe('AutonomyPanel', () => { test('loads the current value on mount', async () => { mockGet.mockResolvedValue({ result: autonomy(250), logs: [] }); - renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + renderWithProviders(, { + initialEntries: ['/settings/agent-access'], + }); const input = (await screen.findByLabelText(/Max actions per hour/i)) as HTMLInputElement; await waitFor(() => expect(input).toHaveValue(250)); }); test('Save is disabled until the value changes', async () => { mockGet.mockResolvedValue({ result: autonomy(20), logs: [] }); - renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + renderWithProviders(, { + initialEntries: ['/settings/agent-access'], + }); const saveBtn = await screen.findByRole('button', { name: /^Save$/ }); expect(saveBtn).toBeDisabled(); @@ -75,7 +71,9 @@ describe('AutonomyPanel', () => { result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' }, logs: [], }); - renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + renderWithProviders(, { + initialEntries: ['/settings/agent-access'], + }); const input = await screen.findByDisplayValue('20'); fireEvent.change(input, { target: { value: '300' } }); fireEvent.click(screen.getByRole('button', { name: /^Save$/ })); @@ -85,7 +83,9 @@ describe('AutonomyPanel', () => { test('shows inline validation when the value is out of range', async () => { mockGet.mockResolvedValue({ result: autonomy(20), logs: [] }); - renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + renderWithProviders(, { + initialEntries: ['/settings/agent-access'], + }); const input = await screen.findByDisplayValue('20'); fireEvent.change(input, { target: { value: '0' } }); await screen.findByText(/Must be a positive integer/i); @@ -97,7 +97,9 @@ describe('AutonomyPanel', () => { // can receive that input through normal UI flow. test.each(['1.5', '1e2', '-5', '0.0'])('rejects non-integer input %s', async value => { mockGet.mockResolvedValue({ result: autonomy(20), logs: [] }); - renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + renderWithProviders(, { + initialEntries: ['/settings/agent-access'], + }); const input = await screen.findByDisplayValue('20'); fireEvent.change(input, { target: { value } }); await screen.findByText(/Must be a positive integer/i); @@ -107,7 +109,9 @@ describe('AutonomyPanel', () => { test('surfaces RPC errors and reverts to the last committed value', async () => { mockGet.mockResolvedValue({ result: autonomy(50), logs: [] }); mockUpdate.mockRejectedValue(new Error('disk full')); - renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + renderWithProviders(, { + initialEntries: ['/settings/agent-access'], + }); const input = (await screen.findByDisplayValue('50')) as HTMLInputElement; fireEvent.change(input, { target: { value: '500' } }); fireEvent.click(screen.getByRole('button', { name: /^Save$/ })); @@ -120,7 +124,9 @@ describe('AutonomyPanel', () => { test('clicking the 100 preset sets the draft to 100', async () => { mockGet.mockResolvedValue({ result: autonomy(20), logs: [] }); - renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + renderWithProviders(, { + initialEntries: ['/settings/agent-access'], + }); const input = (await screen.findByDisplayValue('20')) as HTMLInputElement; fireEvent.click(screen.getByRole('button', { name: '100' })); @@ -130,7 +136,9 @@ describe('AutonomyPanel', () => { test('clicking the 500 preset sets the draft to 500', async () => { mockGet.mockResolvedValue({ result: autonomy(20), logs: [] }); - renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + renderWithProviders(, { + initialEntries: ['/settings/agent-access'], + }); const input = (await screen.findByDisplayValue('20')) as HTMLInputElement; fireEvent.click(screen.getByRole('button', { name: '500' })); @@ -139,7 +147,9 @@ describe('AutonomyPanel', () => { test('clicking the 1000 preset sets the draft to 1000', async () => { mockGet.mockResolvedValue({ result: autonomy(20), logs: [] }); - renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + renderWithProviders(, { + initialEntries: ['/settings/agent-access'], + }); const input = (await screen.findByDisplayValue('20')) as HTMLInputElement; fireEvent.click(screen.getByRole('button', { name: '1000' })); @@ -148,7 +158,9 @@ describe('AutonomyPanel', () => { test('clicking Unlimited preset sets draft to UNLIMITED sentinel and shows note', async () => { mockGet.mockResolvedValue({ result: autonomy(20), logs: [] }); - renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + renderWithProviders(, { + initialEntries: ['/settings/agent-access'], + }); await screen.findByDisplayValue('20'); // The Unlimited preset button uses i18n key autonomy.presetUnlimited @@ -170,7 +182,9 @@ describe('AutonomyPanel', () => { result: { config: {}, workspace_dir: '/tmp', config_path: '/tmp/cfg.toml' }, logs: [], }); - renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + renderWithProviders(, { + initialEntries: ['/settings/agent-access'], + }); const input = await screen.findByDisplayValue('20'); fireEvent.change(input, { target: { value: '300' } }); @@ -186,7 +200,9 @@ describe('AutonomyPanel', () => { test('editing the field after error clears the error status', async () => { mockGet.mockResolvedValue({ result: autonomy(50), logs: [] }); mockUpdate.mockRejectedValue(new Error('disk full')); - renderWithProviders(, { initialEntries: ['/settings/autonomy'] }); + renderWithProviders(, { + initialEntries: ['/settings/agent-access'], + }); const input = await screen.findByDisplayValue('50'); fireEvent.change(input, { target: { value: '500' } }); diff --git a/app/src/components/settings/panels/__tests__/IntegrationsPanel.test.tsx b/app/src/components/settings/panels/__tests__/IntegrationsPanel.test.tsx new file mode 100644 index 000000000..bce050f84 --- /dev/null +++ b/app/src/components/settings/panels/__tests__/IntegrationsPanel.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { renderWithProviders } from '../../../../test/test-utils'; +import IntegrationsPanel from '../IntegrationsPanel'; + +// The tab bodies have their own test suites — stub them so these tests stay +// focused on the hash <-> tab mapping that IntegrationsPanel owns. +vi.mock('../TaskSourcesPanel', () => ({ + default: ({ embedded }: { embedded?: boolean }) => ( +
+ ), +})); + +vi.mock('../ComposioPanel', () => ({ + default: ({ embedded }: { embedded?: boolean }) => ( +
+ ), +})); + +vi.mock('../../../../pages/Webhooks', () => ({ + default: ({ embedded }: { embedded?: boolean }) => ( +
+ ), +})); + +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ + navigateBack: vi.fn(), + navigateToSettings: vi.fn(), + breadcrumbs: [], + }), +})); + +describe('IntegrationsPanel', () => { + test('default hash renders the Task sources tab embedded', () => { + renderWithProviders(, { initialEntries: ['/settings/integrations'] }); + + expect(screen.getByTestId('integrations-tab-task-sources')).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByTestId('stub-task-sources')).toHaveAttribute('data-embedded', 'true'); + expect(screen.queryByTestId('stub-composio')).not.toBeInTheDocument(); + expect(screen.queryByTestId('stub-webhooks')).not.toBeInTheDocument(); + }); + + test('#composio hash selects the Composio tab embedded', () => { + renderWithProviders(, { + initialEntries: ['/settings/integrations#composio'], + }); + + expect(screen.getByTestId('integrations-tab-composio')).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByTestId('stub-composio')).toHaveAttribute('data-embedded', 'true'); + expect(screen.queryByTestId('stub-task-sources')).not.toBeInTheDocument(); + }); + + test('#webhooks hash selects the Webhooks tab embedded', () => { + renderWithProviders(, { + initialEntries: ['/settings/integrations#webhooks'], + }); + + expect(screen.getByTestId('integrations-tab-webhooks')).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByTestId('stub-webhooks')).toHaveAttribute('data-embedded', 'true'); + }); + + test('clicking tabs switches the view in place', async () => { + renderWithProviders(, { initialEntries: ['/settings/integrations'] }); + + fireEvent.click(screen.getByTestId('integrations-tab-composio')); + await screen.findByTestId('stub-composio'); + + fireEvent.click(screen.getByTestId('integrations-tab-webhooks')); + await screen.findByTestId('stub-webhooks'); + + fireEvent.click(screen.getByTestId('integrations-tab-task-sources')); + await screen.findByTestId('stub-task-sources'); + }); +}); diff --git a/app/src/components/settings/panels/__tests__/PersonalityPanel.test.tsx b/app/src/components/settings/panels/__tests__/PersonalityPanel.test.tsx new file mode 100644 index 000000000..f1184f09c --- /dev/null +++ b/app/src/components/settings/panels/__tests__/PersonalityPanel.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { renderWithProviders } from '../../../../test/test-utils'; +import PersonalityPanel from '../PersonalityPanel'; + +// The tab bodies have their own test suites — stub them so these tests stay +// focused on the hash <-> tab mapping that PersonalityPanel owns. +vi.mock('../PersonaPanel', () => ({ + default: ({ embedded }: { embedded?: boolean }) => ( +
+ ), +})); + +vi.mock('../MascotPanel', () => ({ + default: ({ embedded }: { embedded?: boolean }) => ( +
+ ), +})); + +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ + navigateBack: vi.fn(), + navigateToSettings: vi.fn(), + breadcrumbs: [], + }), +})); + +describe('PersonalityPanel', () => { + test('default hash renders the Personality tab with the embedded persona editor', () => { + renderWithProviders(, { initialEntries: ['/settings/personality'] }); + + expect(screen.getByTestId('personality-tab-personality')).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByTestId('stub-persona')).toHaveAttribute('data-embedded', 'true'); + expect(screen.queryByTestId('stub-mascot')).not.toBeInTheDocument(); + }); + + test('#face hash selects the Face tab with the embedded mascot panel', () => { + renderWithProviders(, { initialEntries: ['/settings/personality#face'] }); + + expect(screen.getByTestId('personality-tab-face')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByTestId('stub-mascot')).toHaveAttribute('data-embedded', 'true'); + expect(screen.queryByTestId('stub-persona')).not.toBeInTheDocument(); + }); + + test('clicking the Face tab switches the view in place', async () => { + renderWithProviders(, { initialEntries: ['/settings/personality'] }); + + fireEvent.click(screen.getByTestId('personality-tab-face')); + + await screen.findByTestId('stub-mascot'); + expect(screen.queryByTestId('stub-persona')).not.toBeInTheDocument(); + }); + + test('clicking Personality from the Face tab restores the persona editor', async () => { + renderWithProviders(, { initialEntries: ['/settings/personality#face'] }); + + fireEvent.click(screen.getByTestId('personality-tab-personality')); + + await screen.findByTestId('stub-persona'); + expect(screen.queryByTestId('stub-mascot')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/panels/__tests__/UsagePanel.test.tsx b/app/src/components/settings/panels/__tests__/UsagePanel.test.tsx new file mode 100644 index 000000000..080d4541f --- /dev/null +++ b/app/src/components/settings/panels/__tests__/UsagePanel.test.tsx @@ -0,0 +1,104 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { loadAISettings } from '../../../../services/api/aiSettingsApi'; +import { renderWithProviders } from '../../../../test/test-utils'; +import UsagePanel from '../UsagePanel'; + +// The tab bodies are heavy (chart pipeline, multi-RPC fetches) — stub them so +// these tests stay focused on the hash <-> tab mapping that UsagePanel owns. +vi.mock('../../../dashboard/CostDashboardPanel', () => ({ + default: ({ embedded }: { embedded?: boolean }) => ( +
+ ), +})); + +vi.mock('../AIPanel', () => ({ + BackgroundLoopControls: ({ view, hideHeader }: { view?: string; hideHeader?: boolean }) => ( +
+ ), +})); + +vi.mock('../../../../services/api/aiSettingsApi', async () => { + const actual = await vi.importActual( + '../../../../services/api/aiSettingsApi' + ); + return { ...actual, loadAISettings: vi.fn() }; +}); + +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ + navigateBack: vi.fn(), + navigateToSettings: vi.fn(), + breadcrumbs: [], + }), +})); + +const mockLoad = vi.mocked(loadAISettings); + +const snapshot = { routing: {}, cloudProviders: [] } as unknown as Awaited< + ReturnType +>; + +describe('UsagePanel', () => { + beforeEach(() => { + mockLoad.mockReset(); + mockLoad.mockResolvedValue(snapshot); + }); + + test('default hash renders the Costs tab with the embedded cost dashboard', () => { + renderWithProviders(, { initialEntries: ['/settings/usage'] }); + + expect(screen.getByTestId('usage-tab-costs')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByTestId('usage-tab-background')).toHaveAttribute('aria-selected', 'false'); + expect(screen.getByTestId('stub-cost-dashboard')).toHaveAttribute('data-embedded', 'true'); + // Costs tab must not pay for the AI-settings snapshot. + expect(mockLoad).not.toHaveBeenCalled(); + }); + + test('#background hash selects the Background tab and renders the loop controls', async () => { + renderWithProviders(, { initialEntries: ['/settings/usage#background'] }); + + expect(screen.getByTestId('usage-tab-background')).toHaveAttribute('aria-selected', 'true'); + expect(screen.queryByTestId('stub-cost-dashboard')).not.toBeInTheDocument(); + + const controls = await screen.findByTestId('stub-background-loops'); + expect(controls).toHaveAttribute('data-view', 'all'); + expect(controls).toHaveAttribute('data-hide-header', 'true'); + expect(mockLoad).toHaveBeenCalledTimes(1); + }); + + test('clicking the Background tab switches the view in place', async () => { + renderWithProviders(, { initialEntries: ['/settings/usage'] }); + + fireEvent.click(screen.getByTestId('usage-tab-background')); + + await screen.findByTestId('stub-background-loops'); + expect(screen.getByTestId('usage-tab-background')).toHaveAttribute('aria-selected', 'true'); + expect(screen.queryByTestId('stub-cost-dashboard')).not.toBeInTheDocument(); + }); + + test('clicking Costs from the Background tab restores the dashboard', async () => { + renderWithProviders(, { initialEntries: ['/settings/usage#background'] }); + await screen.findByTestId('stub-background-loops'); + + fireEvent.click(screen.getByTestId('usage-tab-costs')); + + await screen.findByTestId('stub-cost-dashboard'); + expect(screen.getByTestId('usage-tab-costs')).toHaveAttribute('aria-selected', 'true'); + }); + + test('surfaces a snapshot load failure on the Background tab', async () => { + mockLoad.mockRejectedValue(new Error('rpc down')); + renderWithProviders(, { initialEntries: ['/settings/usage#background'] }); + + await waitFor(() => + expect(screen.getByTestId('usage-background-tab')).toHaveTextContent(/rpc down/) + ); + expect(screen.queryByTestId('stub-background-loops')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/search/SettingsSearchBar.test.tsx b/app/src/components/settings/search/SettingsSearchBar.test.tsx index 10d8ebcaf..8e30e08fa 100644 --- a/app/src/components/settings/search/SettingsSearchBar.test.tsx +++ b/app/src/components/settings/search/SettingsSearchBar.test.tsx @@ -1,27 +1,21 @@ /** - * Tests for SettingsSearchBar — the global Settings search input + result list. + * Tests for SettingsSearchBar — the settings sidebar search input. * - * The translator is mocked to identity so we can assert against the stable i18n - * keys, and the registry's own keys (e.g. 'settings.appearance.title') contain - * the words we search for. Navigation and developer-mode are mocked so the bar - * is exercised in isolation. + * The two-pane restructure reduced this to a plain controlled text input: it no + * longer renders its own result list or performs navigation. The parent + * (SettingsSidebar) consumes the query to filter the visible nav tabs in place, + * so these tests cover only the input's own behavior (typing, Escape, clear). */ import { fireEvent, render, screen } from '@testing-library/react'; import { useState } from 'react'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; import SettingsSearchBar from './SettingsSearchBar'; -const hoisted = vi.hoisted(() => ({ devMode: false, navigate: vi.fn() })); - vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) })); -vi.mock('../../../hooks/useDeveloperMode', () => ({ useDeveloperMode: () => hoisted.devMode })); -vi.mock('../hooks/useSettingsNavigation', () => ({ - useSettingsNavigation: () => ({ navigateToSettings: hoisted.navigate }), -})); // Controlled wrapper so typing flows through value/onValueChange like in -// SettingsHome. +// SettingsSidebar. const Harness = () => { const [value, setValue] = useState(''); return ; @@ -30,56 +24,28 @@ const Harness = () => { const type = (text: string) => fireEvent.change(screen.getByTestId('settings-search-input'), { target: { value: text } }); -beforeEach(() => { - hoisted.devMode = false; - hoisted.navigate.mockReset(); -}); - describe('SettingsSearchBar', () => { - test('shows no results list until a query is entered', () => { + test('renders the input without any embedded result list', () => { render(); + expect(screen.getByTestId('settings-search-input')).toBeTruthy(); + // The bar itself never renders a result dropdown — filtering lives in the + // sidebar. expect(screen.queryByTestId('settings-search-results')).toBeNull(); expect(screen.queryByTestId('settings-search-empty')).toBeNull(); }); - test('filters entries by query and navigates on click', () => { + test('reflects typed text in the controlled value', () => { render(); + const input = screen.getByTestId('settings-search-input') as HTMLInputElement; type('appearance'); - - const result = screen.getByTestId('settings-search-result-appearance'); - expect(result).toBeTruthy(); - - fireEvent.click(result); - expect(hoisted.navigate).toHaveBeenCalledWith('appearance'); + expect(input.value).toBe('appearance'); }); - test('renders an empty state when nothing matches', () => { + test('shows the clear button only once a query is entered', () => { render(); - type('zzzznomatchqq'); - expect(screen.getByTestId('settings-search-empty')).toBeTruthy(); - expect(screen.queryByTestId('settings-search-results')).toBeNull(); - }); - - test('hides developer-only destinations unless developer mode is on', () => { - render(); - // 'cron-jobs' is a devOnly entry. - type('cron'); - expect(screen.queryByTestId('settings-search-result-cron-jobs')).toBeNull(); - }); - - test('surfaces developer-only destinations when developer mode is on', () => { - hoisted.devMode = true; - render(); - type('cron'); - expect(screen.getByTestId('settings-search-result-cron-jobs')).toBeTruthy(); - }); - - test('Enter activates the highlighted result', () => { - render(); - const input = screen.getByTestId('settings-search-input'); + expect(screen.queryByTestId('settings-search-clear')).toBeNull(); type('appearance'); - fireEvent.keyDown(input, { key: 'Enter' }); - expect(hoisted.navigate).toHaveBeenCalledWith('appearance'); + expect(screen.getByTestId('settings-search-clear')).toBeTruthy(); }); test('Escape clears the query', () => { diff --git a/app/src/components/settings/search/SettingsSearchBar.tsx b/app/src/components/settings/search/SettingsSearchBar.tsx index 0d3b26e4e..142deabd8 100644 --- a/app/src/components/settings/search/SettingsSearchBar.tsx +++ b/app/src/components/settings/search/SettingsSearchBar.tsx @@ -1,22 +1,13 @@ // --------------------------------------------------------------------------- // SettingsSearchBar // -// Global search for the Settings tree (Phase 1 — pages / sections / dev tools). -// Renders a search input plus, while a query is active, a ranked result list. -// Selecting a result navigates to that settings route. -// -// Implemented as an ARIA combobox: the input owns `aria-activedescendant`, the -// result list is a `listbox`, and Arrow/Enter/Escape are handled on the input -// so the user never has to move focus off the field. -// -// The component is controlled (`value` / `onValueChange`) so the parent -// (SettingsHome) can hide the normal menu while a search is in progress. +// A plain, full-width search field for the settings sidebar. It is purely a +// controlled text input — it does NOT render its own result list. The parent +// (SettingsSidebar) uses the query to filter the visible nav tabs in place. // --------------------------------------------------------------------------- -import { useCallback, useId, useRef, useState } from 'react'; +import { useRef } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -import { useSettingsSearch } from './useSettingsSearch'; interface SettingsSearchBarProps { value: string; @@ -42,155 +33,43 @@ const ClearIcon = () => ( const SettingsSearchBar = ({ value, onValueChange }: SettingsSearchBarProps) => { const { t } = useT(); - const { navigateToSettings } = useSettingsNavigation(); - const results = useSettingsSearch(value); - const isSearching = value.trim().length > 0; - - const [activeIndex, setActiveIndex] = useState(0); - const [lastValue, setLastValue] = useState(value); const inputRef = useRef(null); - const listboxId = useId(); - const optionId = (index: number) => `${listboxId}-opt-${index}`; - - // Reset the highlighted row whenever the query changes so the active index - // never points past the end of the (possibly shorter) result set. Adjusting - // state during render is React's recommended alternative to a reset effect. - if (value !== lastValue) { - setLastValue(value); - setActiveIndex(0); - } - - const goToResult = useCallback( - (index: number) => { - const result = results[index]; - if (!result) return; - // [settings-search] navigate to the chosen destination and clear the query - // so returning to the menu shows the full list again. - onValueChange(''); - navigateToSettings(result.entry.route); - }, - [results, navigateToSettings, onValueChange] - ); - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (!isSearching) { - if (event.key === 'Escape' && value) { - event.preventDefault(); - onValueChange(''); - } - return; - } - - switch (event.key) { - case 'ArrowDown': - event.preventDefault(); - setActiveIndex(prev => (results.length ? (prev + 1) % results.length : 0)); - break; - case 'ArrowUp': - event.preventDefault(); - setActiveIndex(prev => (results.length ? (prev - 1 + results.length) % results.length : 0)); - break; - case 'Enter': - if (results.length) { - event.preventDefault(); - goToResult(activeIndex); - } - break; - case 'Escape': - event.preventDefault(); - onValueChange(''); - break; - default: - break; - } - }; return ( -
-
- - - - onValueChange(event.target.value)} - onKeyDown={handleKeyDown} - placeholder={t('settings.settingsSearch.placeholder')} - data-testid="settings-search-input" - className="w-full rounded-2xl border border-stone-200 bg-white py-2.5 pl-10 pr-10 text-sm text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-100 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder:text-neutral-500 dark:focus:ring-primary-500/20" - /> - {value && ( - - )} -
- - {isSearching && ( -
- {results.length === 0 ? ( -
- {t('settings.settingsSearch.noResults').replace('{query}', value.trim())} -
- ) : ( -
    - {results.map((result, index) => ( -
  • setActiveIndex(index)} - onClick={() => goToResult(index)} - className={`flex cursor-pointer items-center justify-between gap-3 border-b border-stone-200 px-4 py-3 last:border-b-0 dark:border-neutral-800 ${ - index === activeIndex - ? 'bg-primary-50 dark:bg-primary-500/10' - : 'bg-stone-50 dark:bg-neutral-900/40' - }`}> -
    -
    - {result.title} -
    - {result.description && ( -
    - {result.description} -
    - )} -
    - - {result.section} - -
  • - ))} -
- )} -
+
+ + + + onValueChange(event.target.value)} + onKeyDown={event => { + if (event.key === 'Escape' && value) { + event.preventDefault(); + onValueChange(''); + } + }} + placeholder={t('settings.settingsSearch.placeholder')} + data-testid="settings-search-input" + className="w-full border-0 border-b border-stone-200 bg-transparent py-2.5 pl-10 pr-10 text-sm text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-0 dark:border-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-500" + /> + {value && ( + )}
); diff --git a/app/src/components/settings/search/settingsSearchRegistry.ts b/app/src/components/settings/search/settingsSearchRegistry.ts index 87ab94548..55ab9fa12 100644 --- a/app/src/components/settings/search/settingsSearchRegistry.ts +++ b/app/src/components/settings/search/settingsSearchRegistry.ts @@ -48,7 +48,6 @@ const SECTION_KEY: Record = { ai: 'pages.settings.aiSection.title', agents: 'settings.agentsSection.title', features: 'pages.settings.featuresSection.title', - composio: 'pages.settings.composioSection.title', crypto: 'settings.cryptoSection.title', notifications: 'settings.groups.notifications', developer: 'settings.developerDiagnostics', @@ -57,8 +56,7 @@ const SECTION_KEY: Record = { // Fine-grained badge overrides: top-level 'home' entries map to 'nav.settings' // by default, but some items logically belong to a sub-group badge. const SECTION_BADGE_OVERRIDES: Record = { - persona: 'settings.groups.assistant', - mascot: 'settings.groups.assistant', + personality: 'settings.groups.assistant', appearance: 'settings.groups.account', devices: 'settings.groups.account', 'memory-sync': 'settings.groups.account', @@ -68,7 +66,7 @@ const SECTION_BADGE_OVERRIDES: Record = { ai: 'nav.settings', 'agents-settings': 'nav.settings', features: 'nav.settings', - composio: 'nav.settings', + integrations: 'nav.settings', 'developer-options': 'settings.developerDiagnostics', }; diff --git a/app/src/components/settings/settingsRouteRegistry.ts b/app/src/components/settings/settingsRouteRegistry.ts index 19aef1db3..afff5a009 100644 --- a/app/src/components/settings/settingsRouteRegistry.ts +++ b/app/src/components/settings/settingsRouteRegistry.ts @@ -14,7 +14,6 @@ import debug from 'debug'; // 'ai' → Settings → AI & Models // 'agents' → Settings → Agents // 'features' → Settings → Features -// 'composio' → Settings → Integrations // 'crypto' → Settings → Crypto // 'notifications' → Settings → Notifications // 'developer' → Settings → Developer & Diagnostics (devOnly entries) @@ -28,11 +27,52 @@ export type SettingsSection = | 'ai' | 'agents' | 'features' - | 'composio' | 'crypto' | 'notifications' | 'developer'; +/** + * Sidebar groups for the two-pane settings layout, in display order. The former + * "System" group's Developer & Diagnostics sub-sections are now first-class + * top-level groups. + */ +export type SettingsNavGroup = + | 'general' + | 'assistant' + | 'data' + | 'connections' + | 'knowledgeMemory' + | 'agentsAutonomy' + | 'modelsInference' + | 'automationIntegrations' + | 'diagnosticsLogs'; + +export const NAV_GROUP_ORDER: SettingsNavGroup[] = [ + 'general', + 'assistant', + 'data', + 'connections', + 'knowledgeMemory', + 'agentsAutonomy', + 'modelsInference', + 'automationIntegrations', + 'diagnosticsLogs', +]; + +/** i18n keys for the sidebar group labels. */ +export const NAV_GROUP_LABEL_KEY: Record = { + general: 'settings.navGroups.general', + assistant: 'settings.navGroups.assistant', + data: 'settings.navGroups.data', + connections: 'settings.navGroups.connections', + // Promoted from the old Developer & Diagnostics sub-sections. + knowledgeMemory: 'settings.devGroups.knowledgeMemory', + agentsAutonomy: 'settings.devGroups.agentsAutonomy', + modelsInference: 'settings.devGroups.modelsInference', + automationIntegrations: 'settings.devGroups.automationIntegrations', + diagnosticsLogs: 'settings.devGroups.diagnosticsLogs', +}; + export interface SettingsRegistryEntry { /** Stable unique id — used as the React key, test id, and route slug. */ id: string; @@ -61,6 +101,23 @@ export interface SettingsRegistryEntry { * or programmatic navigation. Not surfaced in any menu. */ hiddenDeepLink?: boolean; + /** + * Sidebar group for the two-pane layout. Presence makes this entry a + * top-level sidebar destination. + */ + navGroup?: SettingsNavGroup; + /** + * Visually emphasise this sidebar entry (e.g. billing/upgrade) with an accent + * colour so it stands out from the regular nav rows. + */ + highlight?: boolean; + /** Sort order within the sidebar group (ascending; defaults to 0). */ + navOrder?: number; + /** + * Id of the sidebar entry this route belongs to. Drives sidebar active-state + * highlighting and the sub-nav pill row shown above the panel. + */ + navParent?: string; } const log = debug('settings:registry'); @@ -88,21 +145,39 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'pages.settings.accountSection.description', section: 'home', searchKeywords: ['profile', 'sign out', 'logout'], + navGroup: 'general', + navOrder: 0, }, { + // appearance also hosts the display-language selector (formerly an inline + // row on the old settings home list). id: 'appearance', titleKey: 'settings.appearance.title', descriptionKey: 'settings.appearance.menuDesc', section: 'home', - searchKeywords: ['theme', 'dark', 'light', 'mode', 'color', 'colour'], + searchKeywords: [ + 'theme', + 'dark', + 'light', + 'mode', + 'color', + 'colour', + 'language', + 'locale', + 'translation', + ], + navGroup: 'general', + navOrder: 1, }, - // Language is inline on SettingsHome (no route) — not registered here. { + // devices: real pairing panel (the old "Coming Soon" stub was removed). id: 'devices', titleKey: 'settings.account.devices', descriptionKey: 'settings.account.devicesDesc', section: 'home', searchKeywords: ['mobile', 'phone', 'ios', 'android', 'pair'], + navGroup: 'general', + navOrder: 3, }, { id: 'memory-sync', @@ -110,79 +185,84 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.dataSync.menuDesc', section: 'home', searchKeywords: ['sync', 'backup', 'data', 'memory'], + navGroup: 'data', + navOrder: 0, }, - // --- Assistant group (section hubs) --- + // --- Assistant group --- + // The old 'ai' and 'agents-settings' hub pages are retired — their slugs + // redirect to /settings/llm and /settings/agents. { - id: 'ai', - titleKey: 'pages.settings.aiSection.title', - descriptionKey: 'pages.settings.aiSection.description', + // personality: merged Personality & Face page (formerly persona and + // mascot — those slugs redirect here). + id: 'personality', + titleKey: 'settings.personalityFace.title', + descriptionKey: 'settings.personalityFace.menuDesc', section: 'home', - searchKeywords: ['ai', 'models', 'inference', 'llm'], + searchKeywords: [ + 'personality', + 'tone', + 'character', + 'persona', + 'face', + 'avatar', + 'mascot', + 'tiny', + ], + navGroup: 'assistant', + navOrder: 2, }, { - id: 'agents-settings', - titleKey: 'settings.agentsSection.title', - descriptionKey: 'settings.agentsSection.description', + // automations: lifecycle-bound workflow rules (renders WorkflowsTab). + // Legacy /routines and /workflows routes redirect to /settings/automations. + id: 'automations', + titleKey: 'workflows.title', + descriptionKey: 'workflows.subtitle', section: 'home', - searchKeywords: ['agents', 'autonomy', 'access'], - }, - { - id: 'persona', - titleKey: 'settings.assistant.personality', - descriptionKey: 'settings.assistant.personalityDesc', - section: 'home', - searchKeywords: ['personality', 'tone', 'character', 'persona'], - }, - { - id: 'mascot', - titleKey: 'settings.assistant.faceMascot', - descriptionKey: 'settings.assistant.faceMascotDesc', - section: 'home', - searchKeywords: ['face', 'avatar', 'tiny', 'character'], + searchKeywords: ['workflow', 'automation', 'routine', 'rule', 'trigger', 'lifecycle'], + navGroup: 'assistant', + navOrder: 4, }, - // --- Features & Integrations group (section hubs) --- + // --- Connections group --- + // The old 'features' hub page is retired — its slug redirects to + // /settings/screen-intelligence; the feature pages are sidebar entries now. { - id: 'features', - titleKey: 'pages.settings.featuresSection.title', - descriptionKey: 'pages.settings.featuresSection.description', + // integrations: merged Integrations page (formerly the composio hub with + // task-sources, composio-routing and webhooks-triggers — those slugs + // redirect here). + id: 'integrations', + titleKey: 'settings.integrations.title', + descriptionKey: 'settings.integrations.menuDesc', section: 'home', - searchKeywords: ['features', 'screen', 'tools', 'companion'], - }, - { - id: 'composio', - titleKey: 'pages.settings.composioSection.title', - descriptionKey: 'pages.settings.composioSection.description', - section: 'home', - searchKeywords: ['integrations', 'composio', 'webhooks', 'tasks'], + searchKeywords: [ + 'integrations', + 'composio', + 'webhooks', + 'triggers', + 'tasks', + 'sources', + 'inbox', + 'routing', + 'oauth', + ], + navGroup: 'connections', + navOrder: 0, }, - // --- Notifications --- - { - id: 'notifications-hub', - titleKey: 'settings.notifications.menuTitle', - descriptionKey: 'settings.notifications.menuDesc', - section: 'home', - searchKeywords: ['alerts', 'push', 'routing'], - }, + // Notifications-hub and crypto hub pages are retired — their slugs redirect + // to /settings/notifications and /settings/wallet-balances. - // --- Crypto --- - { - id: 'crypto', - titleKey: 'settings.cryptoSection.title', - descriptionKey: 'settings.cryptoSection.description', - section: 'home', - searchKeywords: ['crypto', 'wallet', 'recovery'], - }, - - // --- About (always visible, no section header) --- + // --- About --- { id: 'about', titleKey: 'settings.about', descriptionKey: 'settings.aboutDesc', section: 'home', searchKeywords: ['version', 'build', 'update', 'developer mode'], + // Moved out of the retired "System" group; sits at the end of General. + navGroup: 'general', + navOrder: 99, }, // ========================================================================= @@ -194,6 +274,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'pages.settings.account.teamDesc', section: 'account', searchKeywords: ['members', 'invites', 'organization', 'organisation', 'workspace'], + navParent: 'account', }, { id: 'privacy', @@ -201,6 +282,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'pages.settings.account.privacyDesc', section: 'account', searchKeywords: ['telemetry', 'tracking', 'analytics', 'data'], + navParent: 'account', }, { id: 'security', @@ -208,6 +290,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'pages.settings.account.securityDesc', section: 'account', searchKeywords: ['keychain', 'secret', 'password', 'encryption', 'credentials'], + navParent: 'account', }, { id: 'migration', @@ -215,6 +298,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'pages.settings.account.migrationDesc', section: 'account', searchKeywords: ['import', 'export', 'transfer', 'data'], + navParent: 'account', }, // ========================================================================= @@ -226,6 +310,8 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'pages.settings.ai.llmDesc', section: 'ai', searchKeywords: ['model', 'anthropic', 'openai', 'claude', 'provider', 'api key'], + // Surfaced on the Connections page (Intelligence group); route kept for + // deep-link compatibility but no longer in the settings sidebar. }, { id: 'embeddings', @@ -233,6 +319,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'pages.settings.ai.embeddingsDesc', section: 'ai', searchKeywords: ['vector', 'embedding', 'search'], + navParent: 'llm', }, { id: 'voice', @@ -240,26 +327,54 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'pages.settings.ai.voiceDesc', section: 'ai', searchKeywords: ['tts', 'stt', 'speech', 'dictation', 'audio'], + // Surfaced on the Connections page (Intelligence group); route kept for + // deep-link compatibility but no longer in the settings sidebar. }, { - id: 'heartbeat', - titleKey: 'settings.heartbeat.title', - descriptionKey: 'settings.heartbeat.desc', + // usage: merged Usage & Limits page (formerly heartbeat, ledger-usage and + // cost-dashboard — those slugs redirect here). + id: 'usage', + titleKey: 'settings.usage.title', + descriptionKey: 'settings.usage.menuDesc', section: 'ai', + searchKeywords: [ + 'usage', + 'tokens', + 'ledger', + 'cost', + 'spend', + 'billing', + 'budget', + 'heartbeat', + 'loops', + 'background', + ], + // Usage & Limits now lives in the General group (was a sub-page of LLM, + // which has moved to the Connections page). + navGroup: 'general', + navOrder: 2, }, + + // --- Agent profiles (top-level sidebar destination, Assistant group) --- { - id: 'ledger-usage', - titleKey: 'settings.ledgerUsage.title', - descriptionKey: 'settings.ledgerUsage.desc', - section: 'ai', - searchKeywords: ['usage', 'tokens', 'ledger', 'cost'], - }, - { - id: 'cost-dashboard', - titleKey: 'settings.costDashboard.title', - descriptionKey: 'settings.costDashboard.desc', - section: 'ai', - searchKeywords: ['cost', 'spend', 'usage', 'billing'], + // profiles: top-level agent profiles (soul, memory, skills, MCP, connectors). + // Child routes profiles/new and profiles/edit/:id resolve to this entry. + id: 'profiles', + titleKey: 'settings.profiles.title', + descriptionKey: 'settings.profiles.menuDesc', + section: 'home', + searchKeywords: [ + 'profile', + 'profiles', + 'agent', + 'soul', + 'memory', + 'skills', + 'mcp', + 'connectors', + ], + navGroup: 'assistant', + navOrder: 3, }, // ========================================================================= @@ -271,20 +386,27 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.agents.subtitle', section: 'agents', searchKeywords: ['agent', 'profiles'], + navGroup: 'assistant', + navOrder: 4, }, { - id: 'autonomy', - titleKey: 'settings.developerMenu.autonomy.title', - descriptionKey: 'settings.developerMenu.autonomy.desc', - section: 'agents', - searchKeywords: ['autonomy', 'autonomous'], - }, - { + // agent-access also hosts the autonomy rate-limit section (formerly the + // standalone /settings/autonomy page — that slug redirects here). id: 'agent-access', titleKey: 'settings.agentAccess.title', descriptionKey: 'settings.agentAccess.menuDesc', section: 'agents', - searchKeywords: ['access', 'permissions', 'tier', 'security policy'], + searchKeywords: [ + 'access', + 'permissions', + 'tier', + 'security policy', + 'autonomy', + 'autonomous', + 'rate limit', + 'actions per hour', + ], + navParent: 'agents', }, { id: 'activity-level', @@ -292,6 +414,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'activityLevel.description', section: 'agents', searchKeywords: ['background', 'activity', 'subconscious'], + navParent: 'agents', }, { id: 'sandbox-settings', @@ -299,6 +422,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.sandbox.menuDesc', section: 'agents', searchKeywords: ['sandbox', 'jail', 'isolation', 'docker'], + navParent: 'agents', }, // ========================================================================= @@ -310,6 +434,8 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'pages.settings.features.screenAwarenessDesc', section: 'features', searchKeywords: ['screen', 'awareness', 'vision', 'capture'], + navGroup: 'connections', + navOrder: 1, }, { id: 'tools', @@ -317,6 +443,8 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'pages.settings.features.toolsDesc', section: 'features', searchKeywords: ['tools', 'capabilities', 'functions'], + navGroup: 'connections', + navOrder: 2, }, { id: 'companion', @@ -324,31 +452,8 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'pages.settings.features.desktopCompanionDesc', section: 'features', searchKeywords: ['desktop', 'overlay', 'companion'], - }, - - // ========================================================================= - // COMPOSIO / INTEGRATIONS section leaf panels - // ========================================================================= - { - id: 'task-sources', - titleKey: 'settings.taskSources.title', - descriptionKey: 'settings.taskSources.subtitle', - section: 'composio', - searchKeywords: ['tasks', 'sources', 'inbox'], - }, - { - id: 'composio-routing', - titleKey: 'settings.developerMenu.composioRouting.title', - descriptionKey: 'settings.developerMenu.composioRouting.desc', - section: 'composio', - searchKeywords: ['composio', 'routing', 'integrations'], - }, - { - id: 'webhooks-triggers', - titleKey: 'settings.developerMenu.composeioTriggers.title', - descriptionKey: 'settings.developerMenu.composeioTriggers.desc', - section: 'composio', - searchKeywords: ['webhooks', 'triggers', 'composio'], + navGroup: 'connections', + navOrder: 3, }, // ========================================================================= @@ -358,10 +463,12 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ { id: 'notifications', route: 'notifications', - titleKey: 'settings.notificationsHub.settingsItem', - descriptionKey: 'settings.notificationsHub.settingsItemDesc', + titleKey: 'settings.notifications.menuTitle', + descriptionKey: 'settings.notifications.menuDesc', section: 'notifications', searchKeywords: ['alerts', 'push', 'preferences', 'routing'], + navGroup: 'general', + navOrder: 2, }, // ========================================================================= @@ -373,6 +480,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'pages.settings.account.recoveryPhraseDesc', section: 'crypto', searchKeywords: ['mnemonic', 'seed', 'backup', 'recovery', 'wallet'], + navParent: 'wallet-balances', }, { id: 'wallet-balances', @@ -380,6 +488,8 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'pages.settings.account.walletBalancesDesc', section: 'crypto', searchKeywords: ['wallet', 'balance', 'tokens', 'crypto'], + navGroup: 'data', + navOrder: 1, }, // ========================================================================= @@ -393,7 +503,9 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ // (all moved to their canonical section pages). // ========================================================================= { - // developer-options is the section page itself — its breadcrumb is just [Settings]. + // developer-options is the legacy aggregator panel — kept routable for deep + // links, but no longer a sidebar entry now that its children are expanded + // directly into the Developer & Diagnostics group. id: 'developer-options', titleKey: 'settings.developerDiagnostics', descriptionKey: 'settings.developerDiagnosticsDesc', @@ -438,6 +550,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.developerMenu.voiceDebug.desc', section: 'developer', devOnly: true, + navGroup: 'modelsInference', }, { id: 'screen-awareness-debug', @@ -445,6 +558,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.developerMenu.screenAwareness.desc', section: 'developer', devOnly: true, + navGroup: 'modelsInference', }, { id: 'event-log', @@ -452,6 +566,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.developerMenu.eventLog.desc', section: 'developer', devOnly: true, + navGroup: 'diagnosticsLogs', searchKeywords: ['events', 'log'], }, { @@ -460,6 +575,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'devOptions.toolPolicyDiagnosticsDesc', section: 'developer', devOnly: true, + navGroup: 'agentsAutonomy', }, { id: 'model-health', @@ -467,6 +583,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.modelHealth.desc', section: 'developer', devOnly: true, + navGroup: 'modelsInference', }, { id: 'webhooks-debug', @@ -474,6 +591,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.developerMenu.webhooks.desc', section: 'developer', devOnly: true, + navGroup: 'automationIntegrations', }, // Automation & Integrations (debug) { @@ -482,6 +600,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.developerMenu.mcpServer.desc', section: 'developer', devOnly: true, + navGroup: 'automationIntegrations', searchKeywords: ['mcp', 'server'], }, { @@ -490,6 +609,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.developerMenu.devWorkflow.desc', section: 'developer', devOnly: true, + navGroup: 'automationIntegrations', }, { id: 'cron-jobs', @@ -497,6 +617,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.developerMenu.cronJobs.desc', section: 'developer', devOnly: true, + navGroup: 'automationIntegrations', searchKeywords: ['cron', 'schedule', 'jobs'], }, { @@ -505,6 +626,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.developerMenu.tasks.desc', section: 'developer', devOnly: true, + navGroup: 'automationIntegrations', }, { // composio-triggers: renders ComposioTriagePanel — debug alias kept under Developer Options. @@ -513,6 +635,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.developerMenu.composio.desc', section: 'developer', devOnly: true, + navGroup: 'automationIntegrations', }, // Agent debug { @@ -521,6 +644,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.developerMenu.agentChat.desc', section: 'developer', devOnly: true, + navGroup: 'agentsAutonomy', }, { id: 'local-model-debug', @@ -528,6 +652,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.developerMenu.localModelDebug.desc', section: 'developer', devOnly: true, + navGroup: 'agentsAutonomy', }, { id: 'skills-runner', @@ -535,6 +660,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.developerMenu.skillsRunner.desc', section: 'developer', devOnly: true, + navGroup: 'agentsAutonomy', }, { id: 'autocomplete-debug', @@ -542,6 +668,7 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.developerMenu.autocomplete.desc', section: 'developer', devOnly: true, + navGroup: 'modelsInference', }, // Build Info (about page alias in dev menu) { @@ -551,18 +678,21 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ descriptionKey: 'settings.buildInfo.menuDesc', section: 'developer', devOnly: true, + navGroup: 'diagnosticsLogs', }, // ========================================================================= // INTENTIONALLY HIDDEN / DEEP-LINK ONLY (not surfaced in any menu) // ========================================================================= { - // billing: deep-link only — avatar menu navigates here directly. - // Route retained; no home entry by design. + // billing: surfaced in the General group (also opened from the avatar menu). id: 'billing', - titleKey: 'settings.billing.movedToWeb', + titleKey: 'nav.avatarMenu.billing', section: 'home', - hiddenDeepLink: true, + searchKeywords: ['billing', 'subscription', 'payment', 'plan', 'invoice'], + navGroup: 'general', + navOrder: 4, + highlight: true, }, { // autocomplete: hidden per #717 (route retained for re-enable). @@ -573,12 +703,14 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ devOnly: true, }, { - // search: internal search debug panel, not surfaced in the main nav. + // search: web search engine settings (Brave / Google / Tavily provider). + // Surfaced on the Connections page (Intelligence group); route kept for + // deep-link compatibility but no longer in the settings sidebar. id: 'search', titleKey: 'settings.search.title', section: 'developer', - hiddenDeepLink: true, devOnly: true, + searchKeywords: ['search', 'engine', 'web', 'brave', 'google', 'tavily', 'provider'], }, { // permissions: moved to developer options, not a standalone home entry. @@ -593,7 +725,8 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ id: 'approval-history', titleKey: 'settings.approvalHistory.title', section: 'agents', - hiddenDeepLink: true, + searchKeywords: ['approval', 'history', 'permission', 'audit'], + navGroup: 'agentsAutonomy', }, ]; @@ -616,6 +749,54 @@ export const findEntryById = (id: string): SettingsRegistryEntry | undefined => export const findEntryByRoute = (route: string): SettingsRegistryEntry | undefined => SETTINGS_ROUTE_REGISTRY.find(e => entryRoute(e) === route); +// --------------------------------------------------------------------------- +// Sidebar helpers (two-pane layout) +// --------------------------------------------------------------------------- + +export interface SettingsSidebarGroup { + group: SettingsNavGroup; + entries: SettingsRegistryEntry[]; +} + +/** Ordered sidebar groups with their (ordered, visible) entries. */ +export const sidebarGroups = (): SettingsSidebarGroup[] => + NAV_GROUP_ORDER.map(group => ({ + group, + entries: SETTINGS_ROUTE_REGISTRY.filter(e => e.navGroup === group && !e.hiddenDeepLink).sort( + (a, b) => (a.navOrder ?? 0) - (b.navOrder ?? 0) + ), + })).filter(g => g.entries.length > 0); + +/** + * Resolves the sidebar entry id to highlight for a given route id. Follows + * `navParent` chains; routes under the developer section highlight the + * Developer & Diagnostics entry. + */ +export const resolveSidebarId = (routeId: string): string | undefined => { + const entry = findEntryById(routeId) ?? findEntryByRoute(routeId); + if (!entry) return undefined; + if (entry.navGroup) return entry.id; + if (entry.navParent) { + return resolveSidebarId(entry.navParent) ?? entry.navParent; + } + if (entry.section === 'developer') return 'developer-options'; + return undefined; +}; + +/** + * Sub-nav family for a sidebar entry: the entry itself followed by its + * visible children. Returns [] when the entry has no children (no sub-nav + * row is rendered). + */ +export const subNavSiblings = (sidebarId: string): SettingsRegistryEntry[] => { + const parent = findEntryById(sidebarId); + if (!parent?.navGroup) return []; + const children = SETTINGS_ROUTE_REGISTRY.filter( + e => e.navParent === sidebarId && !e.hiddenDeepLink && !e.devOnly + ); + return children.length > 0 ? [parent, ...children] : []; +}; + // Debug log: confirm registry loaded. if (typeof window !== 'undefined') { log('route registry loaded — %d entries', SETTINGS_ROUTE_REGISTRY.length); diff --git a/app/src/hooks/useMediaQuery.ts b/app/src/hooks/useMediaQuery.ts new file mode 100644 index 000000000..4baf42855 --- /dev/null +++ b/app/src/hooks/useMediaQuery.ts @@ -0,0 +1,27 @@ +import { useSyncExternalStore } from 'react'; + +/** + * Subscribes to a CSS media query and returns whether it currently matches. + * SSR/test-safe: returns false when matchMedia is unavailable. + */ +export const useMediaQuery = (query: string): boolean => { + const subscribe = (onStoreChange: () => void) => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return () => {}; + } + const mql = window.matchMedia(query); + mql.addEventListener('change', onStoreChange); + return () => mql.removeEventListener('change', onStoreChange); + }; + + const getSnapshot = () => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return false; + } + return window.matchMedia(query).matches; + }; + + return useSyncExternalStore(subscribe, getSnapshot, () => false); +}; + +export default useMediaQuery; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index c5dc106b0..38cc6bfef 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -32,6 +32,9 @@ const messages: TranslationMap = { 'brain.subtitle': 'خريطة معرفتك ومصادر الذاكرة وعناصر التحكم.', 'brain.tabs.memory': 'الذاكرة', 'brain.tabs.subconscious': 'اللاوعي', + 'brain.tabs.graph': 'الرسم البياني', + 'brain.tabs.sources': 'المصادر', + 'brain.tabs.sync': 'المزامنة', 'brain.empty': 'دماغك فارغ في الوقت الحالي — قم بربط مصدر لبدء بناء الذاكرة.', 'brain.error': 'تعذّر تحميل دماغك. يرجى المحاولة مرة أخرى.', 'common.cancel': 'إلغاء', @@ -88,11 +91,11 @@ const messages: TranslationMap = { 'settings.groups.notifications': 'الإشعارات', 'settings.groups.about': 'حول', 'settings.assistant.personality': 'الشخصية', - 'settings.assistant.personalityDesc': 'الاسم والوصف وشخصية SOUL.md', + 'settings.personalityFace.title': 'الشخصية والوجه', + 'settings.personalityFace.menuDesc': 'اضبط شخصية مساعدك واختر وجهه', 'settings.assistant.voice': 'الصوت', 'settings.assistant.voiceDesc': 'إعدادات التحويل من كلام إلى نص ومن نص إلى كلام', 'settings.assistant.faceMascot': 'الوجه / الشخصية', - 'settings.assistant.faceMascotDesc': 'اختر لون الشخصية المستخدم في التطبيق', 'settings.assistant.backgroundActivity': 'اللاوعي', 'settings.assistant.backgroundActivityDesc': 'تحكم في مدى نشاط مساعدك في الخلفية', 'settings.assistant.screenAwareness': 'الوعي بالشاشة', @@ -166,6 +169,11 @@ const messages: TranslationMap = { 'settings.exitLocalSession': 'الخروج من الجلسة المحلية', 'settings.exitLocalSessionDesc': 'العودة إلى شاشة تسجيل الدخول', 'settings.language': 'اللغة', + 'settings.navGroups.general': 'عام', + 'settings.navGroups.assistant': 'المساعد', + 'settings.navGroups.data': 'البيانات', + 'settings.navGroups.connections': 'الاتصالات', + 'settings.navGroups.system': 'النظام', 'settings.betaBuild': 'الإصدار التجريبي - v{version}', 'settings.languageDesc': 'لغة عرض واجهة التطبيق', 'settings.alerts': 'التنبيهات', @@ -383,6 +391,8 @@ const messages: TranslationMap = { 'connections.tabs.mcp': 'خوادم MCP', 'connections.tabs.skills': 'المهارات', 'connections.tabs.meetings': 'الاجتماعات', + 'connections.groups.integrations': 'التكاملات', + 'connections.groups.intelligence': 'الذكاء', 'memory.title': 'الذاكرة', 'memory.search': 'البحث في الذكريات...', 'memory.noResults': 'لم يتم العثور على ذكريات', @@ -881,13 +891,9 @@ const messages: TranslationMap = { 'settings.about.connectionHelperCloud': 'متصلة بقاعدة نائية غيّر هذا في (بوتشيك) أو مُخلّق السحابة.', 'settings.heartbeat.title': 'نبضات القلب والحلقات', - 'settings.heartbeat.desc': 'التحكم في إيقاعات جدولة الخلفية وفحص خريطة الحلقة.', - 'settings.ledgerUsage.title': 'دفتر الأستاذ الاستخدام', - 'settings.ledgerUsage.desc': - 'وتُقرأ الميزانية في الآونة الأخيرة، وحسابات الميزانية، والخلفية Xqx0xx.', + 'settings.usage.title': 'الاستخدام والحدود', + 'settings.usage.menuDesc': 'التكاليف واستخدام الرموز والميزانيات والنشاط في الخلفية', 'settings.costDashboard.title': 'لوحة بيانات التكاليف', - 'settings.costDashboard.desc': - 'قضاء سبعة أيام والحرق عبر الحزام، مع سرعة الميزانية وانهيار كل نموذج.', 'settings.costDashboard.sevenDayCost': 'التكلفة اليومية', 'settings.costDashboard.sevenDayTokens': '7 أيام', 'settings.costDashboard.totalSpend': 'المجموع 7 أيام', @@ -1890,6 +1896,9 @@ const messages: TranslationMap = { 'chat.editThreadTitle': 'تعديل عنوان المحادثة', 'chat.hideSidebar': 'إخفاء الشريط الجانبي', 'chat.showSidebar': 'إظهار الشريط الجانبي', + 'chat.searchThreads': 'البحث في المحادثات', + 'layout.resizeSidebar': 'تغيير حجم الشريط الجانبي', + 'layout.showSidebar': 'إظهار الشريط الجانبي', 'chat.newThreadShortcut': 'محادثة جديدة (/new)', 'chat.new': 'جديد', 'chat.failedToLoadMessages': 'فشل تحميل الرسائل', @@ -3011,9 +3020,6 @@ const messages: TranslationMap = { 'pages.settings.ai.voiceDesc': 'وصف الصوت', 'pages.settings.aiSection.description': 'مزودو نماذج اللغة وOllama المحلي والصوت (STT / TTS).', 'pages.settings.aiSection.title': 'الذكاء الاصطناعي', - 'pages.settings.composioSection.title': 'Composio', - 'pages.settings.composioSection.description': - 'التوجيه والمشغلات وسجل عمليات التكامل المدعومة بواسطة Composio.', 'settings.developerMenu.composio.title': 'Composio', 'settings.developerMenu.composio.desc': 'وضع التوجيه ومشغلات التكامل وأرشيف محفوظات التشغيل.', 'pages.settings.features.desktopCompanion': 'الرفيق المكتبي', @@ -4655,6 +4661,8 @@ const messages: TranslationMap = { 'walletSend.done': 'تم', 'walletSend.genericError': 'تعذّر إكمال التحويل. يرجى المحاولة مرة أخرى.', 'settings.taskSources.title': 'المصادر', + 'settings.integrations.title': 'التكاملات', + 'settings.integrations.menuDesc': 'مصادر المهام وتوجيه Composio ومشغلات الويب هوك', 'settings.taskSources.subtitle': 'سحب المهام من أدواتك على لوحة العميل (تود)', 'settings.taskSources.description': 'اجمع عناصر العمل من GitHub وNotion وLinear وClickUp، وأثرها، وقم بتوجيهها إلى لوحة مهام الوكيل.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index ef4a84955..89eec79b0 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -32,6 +32,9 @@ const messages: TranslationMap = { 'brain.subtitle': 'আপনার জ্ঞান গ্রাফ, মেমরি উৎস এবং নিয়ন্ত্রণ।', 'brain.tabs.memory': 'স্মৃতি', 'brain.tabs.subconscious': 'অবচেতন', + 'brain.tabs.graph': 'গ্রাফ', + 'brain.tabs.sources': 'উৎস', + 'brain.tabs.sync': 'সিঙ্ক', 'brain.empty': 'আপনার ব্রেইন এখন খালি — মেমরি তৈরি শুরু করতে একটি উৎস সংযুক্ত করুন।', 'brain.error': 'আপনার ব্রেইন লোড করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।', 'common.cancel': 'বাতিল', @@ -88,11 +91,11 @@ const messages: TranslationMap = { 'settings.groups.notifications': 'বিজ্ঞপ্তি', 'settings.groups.about': 'সম্পর্কে', 'settings.assistant.personality': 'ব্যক্তিত্ব', - 'settings.assistant.personalityDesc': 'নাম, বিবরণ এবং SOUL.md ব্যক্তিত্ব', + 'settings.personalityFace.title': 'ব্যক্তিত্ব ও মুখ', + 'settings.personalityFace.menuDesc': 'আপনার সহকারীর চরিত্র সাজান এবং এর মুখ বেছে নিন', 'settings.assistant.voice': 'ভয়েস', 'settings.assistant.voiceDesc': 'স্পিচ-টু-টেক্সট ও টেক্সট-টু-স্পিচ সেটিংস', 'settings.assistant.faceMascot': 'মুখ / মাসকট', - 'settings.assistant.faceMascotDesc': 'অ্যাপ জুড়ে ব্যবহৃত মাসকট রঙ বাছুন', 'settings.assistant.backgroundActivity': 'সাবকনশাস', 'settings.assistant.backgroundActivityDesc': 'আপনার সহকারী পটভূমিতে কতটা সক্রিয় তা নিয়ন্ত্রণ করুন', @@ -168,6 +171,11 @@ const messages: TranslationMap = { 'settings.exitLocalSession': 'স্থানীয় সেশন থেকে প্রস্থান করুন', 'settings.exitLocalSessionDesc': 'সাইন-ইন স্ক্রিনে ফিরে যান', 'settings.language': 'ভাষা', + 'settings.navGroups.general': 'সাধারণ', + 'settings.navGroups.assistant': 'সহকারী', + 'settings.navGroups.data': 'ডেটা', + 'settings.navGroups.connections': 'সংযোগ', + 'settings.navGroups.system': 'সিস্টেম', 'settings.betaBuild': 'বিটা বিল্ড - v{version}', 'settings.languageDesc': 'অ্যাপ ইন্টারফেসের প্রদর্শন ভাষা', 'settings.alerts': 'সতর্কতা', @@ -388,6 +396,8 @@ const messages: TranslationMap = { 'connections.tabs.mcp': 'MCP সার্ভার', 'connections.tabs.skills': 'দক্ষতা', 'connections.tabs.meetings': 'মিটিং', + 'connections.groups.integrations': 'ইন্টিগ্রেশন', + 'connections.groups.intelligence': 'বুদ্ধিমত্তা', 'memory.title': 'মেমোরি', 'memory.search': 'মেমোরি খুঁজুন...', 'memory.noResults': 'কোনো মেমোরি পাওয়া যায়নি', @@ -897,11 +907,9 @@ const messages: TranslationMap = { 'settings.about.connectionHelperCloud': 'রিমোটের সাথে সংযুক্ত। এটি বুট করা অথবা মেঘের মোড দ্বারা পরিবর্তন করা হবে।', 'settings.heartbeat.title': 'হার্টবিট এবং লুপস', - 'settings.heartbeat.desc': 'ব্যাকরণ কালের সিডিউলিং এবং লুপ ম্যাপ পরীক্ষা করে দেখুন ।', - 'settings.ledgerUsage.title': 'ইউসেজ লেজার', - 'settings.ledgerUsage.desc': 'সাম্প্রতিক ক্রেডিট খরচ, বাজেটের গণিত, এবং পটভূমি API পড়া বাজেট।', + 'settings.usage.title': 'ব্যবহার ও সীমা', + 'settings.usage.menuDesc': 'খরচ, টোকেন ব্যবহার, বাজেট এবং পটভূমির কার্যকলাপ', 'settings.costDashboard.title': 'বিভাজনের স্থান', - 'settings.costDashboard.desc': '৭ দিনের মতো সময় কাটা আর সাইনবোর্ড সব জায়গায় জ্বলতে থাকে।', 'settings.costDashboard.sevenDayCost': '৭ দিনের মূল্য', 'settings.costDashboard.sevenDayTokens': '৭ দিনের ব্যবহার', 'settings.costDashboard.totalSpend': '৭ দিনের মত', @@ -1933,6 +1941,9 @@ const messages: TranslationMap = { 'chat.editThreadTitle': 'থ্রেডের শিরোনাম সম্পাদনা করুন', 'chat.hideSidebar': 'সাইডবার লুকান', 'chat.showSidebar': 'সাইডবার দেখান', + 'chat.searchThreads': 'কথোপকথন অনুসন্ধান করুন', + 'layout.resizeSidebar': 'সাইডবারের আকার পরিবর্তন করুন', + 'layout.showSidebar': 'সাইডবার দেখান', 'chat.newThreadShortcut': 'নতুন থ্রেড (/new)', 'chat.new': 'নতুন', 'chat.failedToLoadMessages': 'বার্তা লোড করতে ব্যর্থ', @@ -3078,9 +3089,6 @@ const messages: TranslationMap = { 'pages.settings.aiSection.description': 'ল্যাঙ্গুয়েজ মডেল প্রোভাইডার, লোকাল Ollama, এবং ভয়েস (STT / TTS)।', 'pages.settings.aiSection.title': 'AI', - 'pages.settings.composioSection.title': 'Composio', - 'pages.settings.composioSection.description': - 'Composio দ্বারা চালিত ইন্টিগ্রেশনের জন্য রাউটিং, ট্রিগার এবং ইতিহাস।', 'settings.developerMenu.composio.title': 'Composio', 'settings.developerMenu.composio.desc': 'রাউটিং মোড, ইন্টিগ্রেশন ট্রিগার, এবং ট্রিগার ইতিহাস।', 'pages.settings.features.desktopCompanion': 'ডেস্কটপ কম্প্যানিয়ন', @@ -4744,6 +4752,8 @@ const messages: TranslationMap = { 'walletSend.done': 'সম্পন্ন', 'walletSend.genericError': 'স্থানান্তর সম্পূর্ণ করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।', 'settings.taskSources.title': 'কাজের উৎস', + 'settings.integrations.title': 'ইন্টিগ্রেশন', + 'settings.integrations.menuDesc': 'টাস্ক সোর্স, Composio রাউটিং এবং ওয়েবহুক ট্রিগার', 'settings.taskSources.subtitle': 'আপনার টুল থেকে Tworet পরিচালনা করুন', 'settings.taskSources.description': 'GitHub, Notion, Linear, এবং ClickUp থেকে কাজের আইটেম সংগ্রহ করুন, সেগুলো সমৃদ্ধ করুন, এবং এজেন্টের টোডো বোর্ডে রুট করুন।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 07b870a65..4c4e250af 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -32,6 +32,9 @@ const messages: TranslationMap = { 'brain.subtitle': 'Dein Wissensgraph, deine Speicherquellen und Steuerelemente.', 'brain.tabs.memory': 'Gedächtnis', 'brain.tabs.subconscious': 'Unterbewusstsein', + 'brain.tabs.graph': 'Graph', + 'brain.tabs.sources': 'Quellen', + 'brain.tabs.sync': 'Synchronisierung', 'brain.empty': 'Dein Gehirn ist noch leer – verbinde eine Quelle, um Speicher aufzubauen.', 'brain.error': 'Dein Gehirn konnte nicht geladen werden. Bitte versuche es erneut.', 'common.cancel': 'Abbrechen', @@ -88,11 +91,12 @@ const messages: TranslationMap = { 'settings.groups.notifications': 'Benachrichtigungen', 'settings.groups.about': 'Über', 'settings.assistant.personality': 'Persönlichkeit', - 'settings.assistant.personalityDesc': 'Name, Beschreibung und SOUL.md-Persona', + 'settings.personalityFace.title': 'Persönlichkeit & Gesicht', + 'settings.personalityFace.menuDesc': + 'Charakter deines Assistenten anpassen und sein Gesicht wählen', 'settings.assistant.voice': 'Stimme', 'settings.assistant.voiceDesc': 'Sprache-zu-Text und Text-zu-Sprache Einstellungen', 'settings.assistant.faceMascot': 'Gesicht / Maskottchen', - 'settings.assistant.faceMascotDesc': 'Maskottchen-Farbe in der App auswählen', 'settings.assistant.backgroundActivity': 'Unterbewusstsein', 'settings.assistant.backgroundActivityDesc': 'Steuern, wie aktiv Ihr Assistent im Hintergrund arbeitet', @@ -170,6 +174,11 @@ const messages: TranslationMap = { 'settings.exitLocalSession': 'Lokale Sitzung beenden', 'settings.exitLocalSessionDesc': 'Zum Anmeldebildschirm zurückkehren', 'settings.language': 'Sprache', + 'settings.navGroups.general': 'Allgemein', + 'settings.navGroups.assistant': 'Assistent', + 'settings.navGroups.data': 'Daten', + 'settings.navGroups.connections': 'Verbindungen', + 'settings.navGroups.system': 'System', 'settings.betaBuild': 'Beta-Build – v{version}', 'settings.languageDesc': 'Anzeigesprache für die App-Oberfläche', 'settings.alerts': 'Warnungen', @@ -399,6 +408,8 @@ const messages: TranslationMap = { 'connections.tabs.mcp': 'MCP-Server', 'connections.tabs.skills': 'Fähigkeiten', 'connections.tabs.meetings': 'Meetings', + 'connections.groups.integrations': 'Integrationen', + 'connections.groups.intelligence': 'Intelligenz', 'memory.title': 'Erinnerung', 'memory.search': 'Erinnerungen suchen...', 'memory.noResults': 'Keine Erinnerungen gefunden', @@ -921,14 +932,9 @@ const messages: TranslationMap = { 'settings.about.connectionHelperCloud': 'Verbunden mit einem Remote-Core. Ändern Sie dies in BootCheck oder in der Cloud-Modus-Auswahl.', 'settings.heartbeat.title': 'Heartbeat und Schleifen', - 'settings.heartbeat.desc': - 'Kontrollieren Sie die Hintergrundplanungskadenzen und inspizieren Sie die Schleifenkarte.', - 'settings.ledgerUsage.title': 'Nutzungsbuch', - 'settings.ledgerUsage.desc': - 'Aktuelle Kreditausgaben, Budgetmathematik und Hintergrund API Lesebudget.', + 'settings.usage.title': 'Nutzung & Limits', + 'settings.usage.menuDesc': 'Kosten, Token-Verbrauch, Budgets und Hintergrundaktivität', 'settings.costDashboard.title': 'Kosten-Dashboard', - 'settings.costDashboard.desc': - '7-tägige Ausgaben und Token brennen über den Schwarm, mit Budgettempo und Aufschlüsselung nach Modell.', 'settings.costDashboard.sevenDayCost': '7-Tage-Tageskosten', 'settings.costDashboard.sevenDayTokens': '7-Tage-Token-Nutzung', 'settings.costDashboard.totalSpend': '7 Tage insgesamt', @@ -1982,6 +1988,9 @@ const messages: TranslationMap = { 'chat.editThreadTitle': 'Thread-Titel bearbeiten', 'chat.hideSidebar': 'Seitenleiste ausblenden', 'chat.showSidebar': 'Seitenleiste anzeigen', + 'chat.searchThreads': 'Unterhaltungen suchen', + 'layout.resizeSidebar': 'Seitenleiste anpassen', + 'layout.showSidebar': 'Seitenleiste anzeigen', 'chat.newThreadShortcut': 'Neuer Thread (/new)', 'chat.new': 'Neu', 'chat.failedToLoadMessages': 'Nachrichten konnten nicht geladen werden', @@ -3155,9 +3164,6 @@ const messages: TranslationMap = { 'pages.settings.aiSection.description': 'Sprachmodellanbieter, lokal Ollama und Sprache (STT / TTS).', 'pages.settings.aiSection.title': 'AI', - 'pages.settings.composioSection.title': 'Composio', - 'pages.settings.composioSection.description': - 'Routing, Trigger und Verlauf für Integrationen, die von Composio unterstützt werden.', 'settings.developerMenu.composio.title': 'Composio', 'settings.developerMenu.composio.desc': 'Routing-Modus, Integrations-Trigger und Trigger-Verlaufsarchiv.', @@ -4873,6 +4879,8 @@ const messages: TranslationMap = { 'walletSend.genericError': 'Überweisung konnte nicht abgeschlossen werden. Bitte versuche es erneut.', 'settings.taskSources.title': 'Aufgabenquellen', + 'settings.integrations.title': 'Integrationen', + 'settings.integrations.menuDesc': 'Aufgabenquellen, Composio-Routing und Webhook-Trigger', 'settings.taskSources.subtitle': 'Ziehen Sie Aufgaben aus Ihren Tools auf das Agenten-ToDo-Board', 'settings.taskSources.description': 'Sammeln Sie Arbeitselemente von GitHub, Notion, Linear und ClickUp, bereichern Sie sie und leiten Sie sie an das Agent-ToDo-Board weiter.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index e742dc288..05019d68c 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -33,6 +33,9 @@ const en: TranslationMap = { 'brain.subtitle': 'Your knowledge graph, memory sources, and controls.', 'brain.tabs.memory': 'Memory', 'brain.tabs.subconscious': 'Subconscious', + 'brain.tabs.graph': 'Graph', + 'brain.tabs.sources': 'Sources', + 'brain.tabs.sync': 'Sync', 'brain.empty': 'Your brain is empty for now — connect a source to start building memory.', 'brain.error': "Couldn't load your brain. Please try again.", @@ -103,11 +106,11 @@ const en: TranslationMap = { // Settings — assistant group items 'settings.assistant.personality': 'Personality', - 'settings.assistant.personalityDesc': 'Name, description, and SOUL.md persona', + 'settings.personalityFace.title': 'Personality & Face', + 'settings.personalityFace.menuDesc': "Tune your assistant's character and pick its face", 'settings.assistant.voice': 'Voice', 'settings.assistant.voiceDesc': 'Speech-to-text and text-to-speech settings', 'settings.assistant.faceMascot': 'Face / Mascot', - 'settings.assistant.faceMascotDesc': 'Pick the mascot color used across the app', 'settings.assistant.backgroundActivity': 'Subconscious', 'settings.assistant.backgroundActivityDesc': 'Control how actively your assistant works in the background', @@ -183,6 +186,11 @@ const en: TranslationMap = { 'settings.exitLocalSession': 'Exit local session', 'settings.exitLocalSessionDesc': 'Return to the sign-in screen', 'settings.language': 'Language', + 'settings.navGroups.general': 'General', + 'settings.navGroups.assistant': 'Assistant', + 'settings.navGroups.data': 'Data', + 'settings.navGroups.connections': 'Connections', + 'settings.navGroups.system': 'System', 'settings.betaBuild': 'Beta build - v{version}', 'settings.languageDesc': 'Display language for the app interface', 'settings.alerts': 'Alerts', @@ -428,6 +436,8 @@ const en: TranslationMap = { 'connections.tabs.mcp': 'MCP Servers', 'connections.tabs.skills': 'Skills', 'connections.tabs.meetings': 'Meetings', + 'connections.groups.integrations': 'Integrations', + 'connections.groups.intelligence': 'Intelligence', // Intelligence / Memory 'memory.title': 'Memory', 'memory.search': 'Search memories...', @@ -1245,12 +1255,9 @@ const en: TranslationMap = { 'settings.about.connectionHelperCloud': 'Connected to a remote core. Change this in BootCheck or the cloud mode picker.', 'settings.heartbeat.title': 'Heartbeat & loops', - 'settings.heartbeat.desc': 'Control background scheduling cadences and inspect the loop map.', - 'settings.ledgerUsage.title': 'Usage ledger', - 'settings.ledgerUsage.desc': 'Recent credit spend, budget math, and background API read budget.', + 'settings.usage.title': 'Usage & Limits', + 'settings.usage.menuDesc': 'Costs, token usage, budgets, and background activity', 'settings.costDashboard.title': 'Cost dashboard', - 'settings.costDashboard.desc': - '7-day spend and token burn across the swarm, with budget pace and per-model breakdown.', 'settings.costDashboard.sevenDayCost': '7-day daily cost', 'settings.costDashboard.sevenDayTokens': '7-day token usage', 'settings.costDashboard.totalSpend': '7-day total', @@ -2331,6 +2338,9 @@ const en: TranslationMap = { 'chat.editThreadTitle': 'Edit thread title', 'chat.hideSidebar': 'Hide sidebar', 'chat.showSidebar': 'Show sidebar', + 'chat.searchThreads': 'Search conversations', + 'layout.resizeSidebar': 'Resize sidebar', + 'layout.showSidebar': 'Show sidebar', 'chat.newThreadShortcut': 'New thread (/new)', 'chat.new': 'New', 'chat.failedToLoadMessages': 'Failed to load messages', @@ -3618,9 +3628,6 @@ const en: TranslationMap = { 'pages.settings.aiSection.description': 'Language model providers, local Ollama, and voice (STT / TTS).', 'pages.settings.aiSection.title': 'AI', - 'pages.settings.composioSection.title': 'Composio', - 'pages.settings.composioSection.description': - 'Routing, triggers, and history for integrations powered by Composio.', 'settings.developerMenu.composio.title': 'Composio', 'settings.developerMenu.composio.desc': 'Routing mode, integration triggers, and trigger history archive.', @@ -5306,6 +5313,8 @@ const en: TranslationMap = { 'walletSend.genericError': 'Could not complete the transfer. Please try again.', // Task sources (#task-sources) 'settings.taskSources.title': 'Task Sources', + 'settings.integrations.title': 'Integrations', + 'settings.integrations.menuDesc': 'Task sources, Composio routing, and webhook triggers', 'settings.taskSources.subtitle': 'Pull tasks from your tools onto the agent todo board', 'settings.taskSources.description': 'Collect work items from GitHub, Notion, Linear, and ClickUp, enrich them, and route them onto the agent todo board.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 0ca155c52..5608f739a 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -32,6 +32,9 @@ const messages: TranslationMap = { 'brain.subtitle': 'Tu grafo de conocimiento, fuentes de memoria y controles.', 'brain.tabs.memory': 'Memoria', 'brain.tabs.subconscious': 'Subconsciente', + 'brain.tabs.graph': 'Gráfico', + 'brain.tabs.sources': 'Fuentes', + 'brain.tabs.sync': 'Sincronización', 'brain.empty': 'Tu cerebro está vacío por ahora: conecta una fuente para empezar a construir tu memoria.', 'brain.error': 'No se pudo cargar tu cerebro. Inténtalo de nuevo.', @@ -89,11 +92,11 @@ const messages: TranslationMap = { 'settings.groups.notifications': 'Notificaciones', 'settings.groups.about': 'Acerca de', 'settings.assistant.personality': 'Personalidad', - 'settings.assistant.personalityDesc': 'Nombre, descripción y persona SOUL.md', + 'settings.personalityFace.title': 'Personalidad y cara', + 'settings.personalityFace.menuDesc': 'Ajusta el carácter de tu asistente y elige su cara', 'settings.assistant.voice': 'Voz', 'settings.assistant.voiceDesc': 'Configuración de voz a texto y texto a voz', 'settings.assistant.faceMascot': 'Cara / Mascota', - 'settings.assistant.faceMascotDesc': 'Elige el color de la mascota en la aplicación', 'settings.assistant.backgroundActivity': 'Subconsciente', 'settings.assistant.backgroundActivityDesc': 'Controla qué tan activo trabaja tu asistente en segundo plano', @@ -173,6 +176,11 @@ const messages: TranslationMap = { 'settings.exitLocalSession': 'Salir de la sesión local', 'settings.exitLocalSessionDesc': 'Volver a la pantalla de inicio de sesión', 'settings.language': 'Idioma', + 'settings.navGroups.general': 'General', + 'settings.navGroups.assistant': 'Asistente', + 'settings.navGroups.data': 'Datos', + 'settings.navGroups.connections': 'Conexiones', + 'settings.navGroups.system': 'Sistema', 'settings.betaBuild': 'Compilación beta: v{version}', 'settings.languageDesc': 'Idioma de visualización de la interfaz de la app', 'settings.alerts': 'Alertas', @@ -401,6 +409,8 @@ const messages: TranslationMap = { 'connections.tabs.mcp': 'Servidores MCP', 'connections.tabs.skills': 'Habilidades', 'connections.tabs.meetings': 'Reuniones', + 'connections.groups.integrations': 'Integraciones', + 'connections.groups.intelligence': 'Inteligencia', 'memory.title': 'Memoria', 'memory.search': 'Buscar recuerdos...', 'memory.noResults': 'No se encontraron recuerdos', @@ -921,14 +931,9 @@ const messages: TranslationMap = { 'settings.about.connectionHelperCloud': 'Conectado a un núcleo remoto. Cambia esto en BootCheck o en el selector de modo en la nube.', 'settings.heartbeat.title': 'Latidos y bucles', - 'settings.heartbeat.desc': - 'Controla los ritmos de programación en segundo plano e inspecciona el mapa del bucle.', - 'settings.ledgerUsage.title': 'Libro mayor de uso', - 'settings.ledgerUsage.desc': - 'Gasto de crédito reciente, matemáticas de presupuesto y presupuesto de lectura de fondo API.', + 'settings.usage.title': 'Uso y límites', + 'settings.usage.menuDesc': 'Costos, uso de tokens, presupuestos y actividad en segundo plano', 'settings.costDashboard.title': 'Panel de costos', - 'settings.costDashboard.desc': - 'Gasto de 7 días y quema de tokens en todo el enjambre, con ritmo de presupuesto y desglose por modelo.', 'settings.costDashboard.sevenDayCost': 'Costo diario de 7 días', 'settings.costDashboard.sevenDayTokens': 'Uso de token de 7 días', 'settings.costDashboard.totalSpend': 'Total de 7 días', @@ -1975,6 +1980,9 @@ const messages: TranslationMap = { 'chat.editThreadTitle': 'Editar título del hilo', 'chat.hideSidebar': 'Ocultar barra lateral', 'chat.showSidebar': 'Mostrar barra lateral', + 'chat.searchThreads': 'Buscar conversaciones', + 'layout.resizeSidebar': 'Redimensionar barra lateral', + 'layout.showSidebar': 'Mostrar barra lateral', 'chat.newThreadShortcut': 'Nuevo hilo (/new)', 'chat.new': 'Nuevo', 'chat.failedToLoadMessages': 'No se pudieron cargar los mensajes', @@ -3138,9 +3146,6 @@ const messages: TranslationMap = { 'pages.settings.aiSection.description': 'Proveedores de modelos de lenguaje, Ollama local y voz (STT / TTS).', 'pages.settings.aiSection.title': 'IA', - 'pages.settings.composioSection.title': 'Composio', - 'pages.settings.composioSection.description': - 'Enrutamiento, activadores e historial para integraciones impulsadas por Composio.', 'settings.developerMenu.composio.title': 'Composio', 'settings.developerMenu.composio.desc': 'Modo de enrutamiento, activadores de integración y archivo de historial de activadores.', @@ -4843,6 +4848,9 @@ const messages: TranslationMap = { 'walletSend.done': 'Hecho', 'walletSend.genericError': 'No se pudo completar la transferencia. Inténtalo de nuevo.', 'settings.taskSources.title': 'Fuentes de tareas', + 'settings.integrations.title': 'Integraciones', + 'settings.integrations.menuDesc': + 'Fuentes de tareas, enrutamiento de Composio y disparadores de webhooks', 'settings.taskSources.subtitle': 'Extrae tareas de tus herramientas al tablero de tareas del agente', 'settings.taskSources.description': diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index aa29d7d76..f16ac64cf 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -32,6 +32,9 @@ const messages: TranslationMap = { 'brain.subtitle': 'Votre graphe de connaissances, vos sources de mémoire et vos commandes.', 'brain.tabs.memory': 'Mémoire', 'brain.tabs.subconscious': 'Subconscient', + 'brain.tabs.graph': 'Graphe', + 'brain.tabs.sources': 'Sources', + 'brain.tabs.sync': 'Synchronisation', 'brain.empty': 'Votre cerveau est vide pour l’instant — connectez une source pour commencer à constituer votre mémoire.', 'brain.error': 'Impossible de charger votre cerveau. Veuillez réessayer.', @@ -89,11 +92,12 @@ const messages: TranslationMap = { 'settings.groups.notifications': 'Notifications', 'settings.groups.about': 'À propos', 'settings.assistant.personality': 'Personnalité', - 'settings.assistant.personalityDesc': 'Nom, description et persona SOUL.md', + 'settings.personalityFace.title': 'Personnalité et visage', + 'settings.personalityFace.menuDesc': + 'Réglez le caractère de votre assistant et choisissez son visage', 'settings.assistant.voice': 'Voix', 'settings.assistant.voiceDesc': 'Paramètres de synthèse vocale et de reconnaissance vocale', 'settings.assistant.faceMascot': 'Visage / Mascotte', - 'settings.assistant.faceMascotDesc': "Choisir la couleur de la mascotte dans l'application", 'settings.assistant.backgroundActivity': 'Subconscient', 'settings.assistant.backgroundActivityDesc': "Contrôler l'activité en arrière-plan de votre assistant", @@ -174,6 +178,11 @@ const messages: TranslationMap = { 'settings.exitLocalSession': 'Quitter le local session', 'settings.exitLocalSessionDesc': "Retour à l'écran de connexion", 'settings.language': 'Langue', + 'settings.navGroups.general': 'Général', + 'settings.navGroups.assistant': 'Assistant', + 'settings.navGroups.data': 'Données', + 'settings.navGroups.connections': 'Connexions', + 'settings.navGroups.system': 'Système', 'settings.betaBuild': 'Version bêta - v{version}', 'settings.languageDesc': "Langue d'affichage de l'interface", 'settings.alerts': 'Alertes', @@ -400,6 +409,8 @@ const messages: TranslationMap = { 'connections.tabs.mcp': 'Serveurs MCP', 'connections.tabs.skills': 'Compétences', 'connections.tabs.meetings': 'Réunions', + 'connections.groups.integrations': 'Intégrations', + 'connections.groups.intelligence': 'Intelligence', 'memory.title': 'Mémoire', 'memory.search': 'Rechercher dans la mémoire…', 'memory.noResults': 'Aucun souvenir trouvé', @@ -922,14 +933,9 @@ const messages: TranslationMap = { 'settings.about.connectionHelperCloud': 'Connecté à un noyau distant. Modifiez cela dans BootCheck ou le sélecteur de mode cloud.', 'settings.heartbeat.title': 'Battement de coeur et boucles', - 'settings.heartbeat.desc': - 'Contrôlez les cadences de planification en arrière-plan et inspectez la carte de boucle.', - 'settings.ledgerUsage.title': "Registre d'utilisation", - 'settings.ledgerUsage.desc': - 'Dépenses de crédit récentes, calcul du budget et lecture du budget de fond API.', + 'settings.usage.title': 'Utilisation et limites', + 'settings.usage.menuDesc': 'Coûts, utilisation des tokens, budgets et activité en arrière-plan', 'settings.costDashboard.title': 'Tableau de bord des coûts', - 'settings.costDashboard.desc': - "Dépense et combustion de tokens sur 7 jours à travers l'essaim, avec rythme budgétaire et répartition par modèle.", 'settings.costDashboard.sevenDayCost': 'Coût quotidien sur 7 jours', 'settings.costDashboard.sevenDayTokens': 'Utilisation du jeton sur 7 jours', 'settings.costDashboard.totalSpend': 'Total sur 7 jours', @@ -1984,6 +1990,9 @@ const messages: TranslationMap = { 'chat.editThreadTitle': 'Modifier le titre du fil', 'chat.hideSidebar': 'Masquer la barre latérale', 'chat.showSidebar': 'Afficher la barre latérale', + 'chat.searchThreads': 'Rechercher des conversations', + 'layout.resizeSidebar': 'Redimensionner la barre latérale', + 'layout.showSidebar': 'Afficher la barre latérale', 'chat.newThreadShortcut': 'Nouveau fil (/new)', 'chat.new': 'Nouveau', 'chat.failedToLoadMessages': 'Échec du chargement des messages', @@ -3150,9 +3159,6 @@ const messages: TranslationMap = { 'pages.settings.aiSection.description': 'Fournisseurs de modèles de langage, Ollama local et voix (STT / TTS).', 'pages.settings.aiSection.title': 'IA', - 'pages.settings.composioSection.title': 'Composio', - 'pages.settings.composioSection.description': - 'Routage, déclencheurs et historique pour les intégrations optimisées par Composio.', 'settings.developerMenu.composio.title': 'Composio', 'settings.developerMenu.composio.desc': "Mode de routage, déclencheurs d'intégration et archive de l'historique des déclencheurs.", @@ -4862,6 +4868,9 @@ const messages: TranslationMap = { 'walletSend.done': 'Terminé', 'walletSend.genericError': 'Impossible de finaliser le transfert. Veuillez réessayer.', 'settings.taskSources.title': 'Sources de tâches', + 'settings.integrations.title': 'Intégrations', + 'settings.integrations.menuDesc': + 'Sources de tâches, routage Composio et déclencheurs de webhooks', 'settings.taskSources.subtitle': "Tirez les tâches de vos outils sur le tableau des tâches de l'agent", 'settings.taskSources.description': diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 54df975cd..088e977a3 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -32,6 +32,9 @@ const messages: TranslationMap = { 'brain.subtitle': 'आपका नॉलेज ग्राफ, मेमोरी स्रोत और नियंत्रण।', 'brain.tabs.memory': 'स्मृति', 'brain.tabs.subconscious': 'अवचेतन', + 'brain.tabs.graph': 'ग्राफ़', + 'brain.tabs.sources': 'स्रोत', + 'brain.tabs.sync': 'सिंक', 'brain.empty': 'आपका ब्रेन अभी खाली है — मेमोरी बनाना शुरू करने के लिए कोई स्रोत कनेक्ट करें।', 'brain.error': 'आपका ब्रेन लोड नहीं हो सका। कृपया फिर से प्रयास करें।', 'common.cancel': 'रद्द करें', @@ -88,11 +91,11 @@ const messages: TranslationMap = { 'settings.groups.notifications': 'सूचनाएं', 'settings.groups.about': 'के बारे में', 'settings.assistant.personality': 'व्यक्तित्व', - 'settings.assistant.personalityDesc': 'नाम, विवरण और SOUL.md व्यक्तित्व', + 'settings.personalityFace.title': 'व्यक्तित्व और चेहरा', + 'settings.personalityFace.menuDesc': 'अपने सहायक का चरित्र समायोजित करें और उसका चेहरा चुनें', 'settings.assistant.voice': 'आवाज़', 'settings.assistant.voiceDesc': 'स्पीच-टू-टेक्स्ट और टेक्स्ट-टू-स्पीच सेटिंग्स', 'settings.assistant.faceMascot': 'चेहरा / शुभंकर', - 'settings.assistant.faceMascotDesc': 'ऐप में उपयोग किया जाने वाला शुभंकर रंग चुनें', 'settings.assistant.backgroundActivity': 'सबकॉन्शस', 'settings.assistant.backgroundActivityDesc': 'नियंत्रित करें कि आपका सहायक पृष्ठभूमि में कितना सक्रिय काम करे', @@ -166,6 +169,11 @@ const messages: TranslationMap = { 'settings.exitLocalSession': 'स्थानीय सत्र से बाहर निकलें', 'settings.exitLocalSessionDesc': 'साइन-इन स्क्रीन पर वापस लौटें', 'settings.language': 'भाषा', + 'settings.navGroups.general': 'सामान्य', + 'settings.navGroups.assistant': 'सहायक', + 'settings.navGroups.data': 'डेटा', + 'settings.navGroups.connections': 'कनेक्शन', + 'settings.navGroups.system': 'सिस्टम', 'settings.betaBuild': 'बीटा बिल्ड - v{version}', 'settings.languageDesc': 'ऐप इंटरफेस की डिस्प्ले भाषा', 'settings.alerts': 'अलर्ट', @@ -387,6 +395,8 @@ const messages: TranslationMap = { 'connections.tabs.mcp': 'MCP सर्वर', 'connections.tabs.skills': 'कौशल', 'connections.tabs.meetings': 'मीटिंग', + 'connections.groups.integrations': 'इंटीग्रेशन', + 'connections.groups.intelligence': 'इंटेलिजेंस', 'memory.title': 'मेमोरी', 'memory.search': 'मेमोरी सर्च करें...', 'memory.noResults': 'कोई मेमोरी नहीं मिली', @@ -894,13 +904,9 @@ const messages: TranslationMap = { 'settings.about.connectionHelperCloud': 'एक दूरस्थ कोर से जुड़ा हुआ है। इसे बूट चेक या क्लाउड मोड पिकर में बदलें।', 'settings.heartbeat.title': 'दिल की धड़कन और लूप', - 'settings.heartbeat.desc': - 'नियंत्रण पृष्ठभूमि शेड्यूलिंग कैडेंस और लूप मानचित्र का निरीक्षण करें।', - 'settings.ledgerUsage.title': 'उपयोग बही', - 'settings.ledgerUsage.desc': 'हाल का क्रेडिट खर्च, बजट गणित, और पृष्ठभूमि API बजट पढ़ें।', + 'settings.usage.title': 'उपयोग और सीमाएँ', + 'settings.usage.menuDesc': 'लागत, टोकन उपयोग, बजट और पृष्ठभूमि गतिविधि', 'settings.costDashboard.title': 'कॉस्ट डैशबोर्ड', - 'settings.costDashboard.desc': - 'बजट की गति और प्रति मॉडल ब्रेकडाउन के साथ, 7-day खर्च और टोकन पूरे दल में जलाते हैं।', 'settings.costDashboard.sevenDayCost': '7 दिन दैनिक लागत', 'settings.costDashboard.sevenDayTokens': '7 दिन टोकन उपयोग', 'settings.costDashboard.totalSpend': '7 दिन कुल', @@ -1932,6 +1938,9 @@ const messages: TranslationMap = { 'chat.editThreadTitle': 'थ्रेड शीर्षक संपादित करें', 'chat.hideSidebar': 'साइडबार छुपाएं', 'chat.showSidebar': 'साइडबार दिखाएं', + 'chat.searchThreads': 'बातचीत खोजें', + 'layout.resizeSidebar': 'साइडबार का आकार बदलें', + 'layout.showSidebar': 'साइडबार दिखाएं', 'chat.newThreadShortcut': 'नई थ्रेड (/new)', 'chat.new': 'नई', 'chat.failedToLoadMessages': 'मैसेज लोड नहीं हो पाए', @@ -3082,9 +3091,6 @@ const messages: TranslationMap = { 'pages.settings.aiSection.description': 'लैंग्वेज मॉडल प्रोवाइडर, लोकल Ollama और वॉइस (STT / TTS)।', 'pages.settings.aiSection.title': 'AI', - 'pages.settings.composioSection.title': 'Composio', - 'pages.settings.composioSection.description': - 'Composio द्वारा संचालित एकीकरण के लिए रूटिंग, ट्रिगर और इतिहास।', 'settings.developerMenu.composio.title': 'Composio', 'settings.developerMenu.composio.desc': 'रूटिंग मोड, एकीकरण ट्रिगर, और ट्रिगर इतिहास संग्रह।', 'pages.settings.features.desktopCompanion': 'डेस्कटॉप कंपैनियन', @@ -4751,6 +4757,8 @@ const messages: TranslationMap = { 'walletSend.done': 'हो गया', 'walletSend.genericError': 'स्थानांतरण पूरा नहीं हो सका। कृपया पुनः प्रयास करें।', 'settings.taskSources.title': 'कार्य स्रोत', + 'settings.integrations.title': 'एकीकरण', + 'settings.integrations.menuDesc': 'कार्य स्रोत, Composio रूटिंग और वेबहुक ट्रिगर', 'settings.taskSources.subtitle': 'एजेंट टोडो बोर्ड पर अपने उपकरणों से कार्य खींचें', 'settings.taskSources.description': 'GitHub, नॉटियन, रैखिक और क्लिकअप से कार्य वस्तुओं को इकट्ठा करें, उन्हें समृद्ध करें और उन्हें एजेंट टोडो बोर्ड पर ले जाएं।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 0d83fcc2a..cfb468b10 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -32,6 +32,9 @@ const messages: TranslationMap = { 'brain.subtitle': 'Grafik pengetahuan, sumber memori, dan kontrol Anda.', 'brain.tabs.memory': 'Memori', 'brain.tabs.subconscious': 'Alam Bawah Sadar', + 'brain.tabs.graph': 'Grafik', + 'brain.tabs.sources': 'Sumber', + 'brain.tabs.sync': 'Sinkronisasi', 'brain.empty': 'Otak Anda masih kosong — hubungkan sumber untuk mulai membangun memori.', 'brain.error': 'Tidak dapat memuat otak Anda. Silakan coba lagi.', 'common.cancel': 'Batal', @@ -88,11 +91,11 @@ const messages: TranslationMap = { 'settings.groups.notifications': 'Notifikasi', 'settings.groups.about': 'Tentang', 'settings.assistant.personality': 'Kepribadian', - 'settings.assistant.personalityDesc': 'Nama, deskripsi, dan persona SOUL.md', + 'settings.personalityFace.title': 'Kepribadian & wajah', + 'settings.personalityFace.menuDesc': 'Sesuaikan karakter asisten Anda dan pilih wajahnya', 'settings.assistant.voice': 'Suara', 'settings.assistant.voiceDesc': 'Pengaturan ucapan-ke-teks dan teks-ke-ucapan', 'settings.assistant.faceMascot': 'Wajah / Maskot', - 'settings.assistant.faceMascotDesc': 'Pilih warna maskot yang digunakan di seluruh aplikasi', 'settings.assistant.backgroundActivity': 'Bawah sadar', 'settings.assistant.backgroundActivityDesc': 'Kontrol seberapa aktif asisten Anda bekerja di latar belakang', @@ -168,6 +171,11 @@ const messages: TranslationMap = { 'settings.exitLocalSession': 'Keluar dari sesi lokal', 'settings.exitLocalSessionDesc': 'Kembali ke layar masuk', 'settings.language': 'Bahasa', + 'settings.navGroups.general': 'Umum', + 'settings.navGroups.assistant': 'Asisten', + 'settings.navGroups.data': 'Data', + 'settings.navGroups.connections': 'Koneksi', + 'settings.navGroups.system': 'Sistem', 'settings.betaBuild': 'Versi beta - v{version}', 'settings.languageDesc': 'Bahasa tampilan untuk antarmuka aplikasi', 'settings.alerts': 'Peringatan', @@ -391,6 +399,8 @@ const messages: TranslationMap = { 'connections.tabs.mcp': 'Server MCP', 'connections.tabs.skills': 'Keterampilan', 'connections.tabs.meetings': 'Rapat', + 'connections.groups.integrations': 'Integrasi', + 'connections.groups.intelligence': 'Intelijen', 'memory.title': 'Memori', 'memory.search': 'Cari memori...', 'memory.noResults': 'Memori tidak ditemukan', @@ -900,13 +910,9 @@ const messages: TranslationMap = { 'settings.about.connectionHelperCloud': 'Terhubung ke inti remote. Ubah ini dalam BootCheck atau mode awan picker.', 'settings.heartbeat.title': 'Detak jantung & loop', - 'settings.heartbeat.desc': 'Kontrol irama penjadwalan latar belakang dan periksa peta loop.', - 'settings.ledgerUsage.title': 'Buku besar penggunaan', - 'settings.ledgerUsage.desc': - 'Menghabiskan kredit baru-baru ini, anggaran matematika, dan latar belakang API membaca anggaran.', + 'settings.usage.title': 'Penggunaan & batas', + 'settings.usage.menuDesc': 'Biaya, penggunaan token, anggaran, dan aktivitas latar belakang', 'settings.costDashboard.title': 'Papan dashboard Biaya', - 'settings.costDashboard.desc': - '7 hari menghabiskan dan token membakar seluruh kawanan, dengan kecepatan anggaran dan rusak.', 'settings.costDashboard.sevenDayCost': '7 hari biaya harian', 'settings.costDashboard.sevenDayTokens': 'Penggunaan token 7 hari', 'settings.costDashboard.totalSpend': 'Total 7 hari', @@ -1937,6 +1943,9 @@ const messages: TranslationMap = { 'chat.editThreadTitle': 'Edit judul utas', 'chat.hideSidebar': 'Sembunyikan sidebar', 'chat.showSidebar': 'Tampilkan sidebar', + 'chat.searchThreads': 'Cari percakapan', + 'layout.resizeSidebar': 'Ubah ukuran sidebar', + 'layout.showSidebar': 'Tampilkan sidebar', 'chat.newThreadShortcut': 'Thread baru (/new)', 'chat.new': 'Baru', 'chat.failedToLoadMessages': 'Gagal memuat pesan', @@ -3087,9 +3096,6 @@ const messages: TranslationMap = { 'pages.settings.aiSection.description': 'Penyedia model bahasa, Ollama lokal, dan suara (STT / TTS).', 'pages.settings.aiSection.title': 'AI', - 'pages.settings.composioSection.title': 'Composio', - 'pages.settings.composioSection.description': - 'Perutean, pemicu, dan riwayat untuk integrasi yang didukung oleh Composio.', 'settings.developerMenu.composio.title': 'Composio', 'settings.developerMenu.composio.desc': 'Mode perutean, pemicu integrasi, dan arsip riwayat pemicu.', @@ -4765,6 +4771,8 @@ const messages: TranslationMap = { 'walletSend.done': 'Selesai', 'walletSend.genericError': 'Tidak dapat menyelesaikan transfer. Silakan coba lagi.', 'settings.taskSources.title': 'Sumber Tugas', + 'settings.integrations.title': 'Integrasi', + 'settings.integrations.menuDesc': 'Sumber tugas, perutean Composio, dan pemicu webhook', 'settings.taskSources.subtitle': 'Tarik tugas dari alat Anda ke papan todo agen', 'settings.taskSources.description': 'Kumpulkan item kerja dari GitHub, Notion, Linear, dan ClickUp, perkaya mereka, dan rute mereka ke agen papan todo.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 279ee2d99..224b6f2ae 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -32,6 +32,9 @@ const messages: TranslationMap = { 'brain.subtitle': 'Il tuo grafo della conoscenza, le fonti di memoria e i controlli.', 'brain.tabs.memory': 'Memoria', 'brain.tabs.subconscious': 'Subconscio', + 'brain.tabs.graph': 'Grafico', + 'brain.tabs.sources': 'Fonti', + 'brain.tabs.sync': 'Sincronizzazione', 'brain.empty': 'Il tuo cervello è ancora vuoto: collega una fonte per iniziare a costruire la memoria.', 'brain.error': 'Impossibile caricare il tuo cervello. Riprova.', @@ -89,11 +92,12 @@ const messages: TranslationMap = { 'settings.groups.notifications': 'Notifiche', 'settings.groups.about': 'Informazioni', 'settings.assistant.personality': 'Personalità', - 'settings.assistant.personalityDesc': 'Nome, descrizione e persona SOUL.md', + 'settings.personalityFace.title': 'Personalità e volto', + 'settings.personalityFace.menuDesc': + 'Regola il carattere del tuo assistente e scegli il suo volto', 'settings.assistant.voice': 'Voce', 'settings.assistant.voiceDesc': 'Impostazioni di sintesi vocale e riconoscimento vocale', 'settings.assistant.faceMascot': 'Faccia / Mascotte', - 'settings.assistant.faceMascotDesc': "Scegli il colore della mascotte usato nell'app", 'settings.assistant.backgroundActivity': 'Subconscio', 'settings.assistant.backgroundActivityDesc': 'Controlla quanto attivamente il tuo assistente lavora in background', @@ -171,6 +175,11 @@ const messages: TranslationMap = { 'settings.exitLocalSession': 'Esci dalla sessione locale', 'settings.exitLocalSessionDesc': 'Ritorna alla schermata di accesso', 'settings.language': 'Lingua', + 'settings.navGroups.general': 'Generale', + 'settings.navGroups.assistant': 'Assistente', + 'settings.navGroups.data': 'Dati', + 'settings.navGroups.connections': 'Connessioni', + 'settings.navGroups.system': 'Sistema', 'settings.betaBuild': 'Build beta - v{version}', 'settings.languageDesc': "Lingua di visualizzazione dell'interfaccia dell'app", 'settings.alerts': 'Avvisi', @@ -396,6 +405,8 @@ const messages: TranslationMap = { 'connections.tabs.mcp': 'Server MCP', 'connections.tabs.skills': 'Competenze', 'connections.tabs.meetings': 'Riunioni', + 'connections.groups.integrations': 'Integrazioni', + 'connections.groups.intelligence': 'Intelligenza', 'memory.title': 'Memoria', 'memory.search': 'Cerca memorie...', 'memory.noResults': 'Nessuna memoria trovata', @@ -914,14 +925,9 @@ const messages: TranslationMap = { 'settings.about.connectionHelperCloud': 'Connesso a un core remoto. Cambia questo in BootCheck o nel selettore della modalità cloud.', 'settings.heartbeat.title': 'Heartbeat e loop', - 'settings.heartbeat.desc': - 'Controlla le cadenze di pianificazione di background e ispeziona la mappa del ciclo.', - 'settings.ledgerUsage.title': 'Registro di utilizzo', - 'settings.ledgerUsage.desc': - 'Spesa recente di credito, matematica del budget e lettura del budget di background API.', + 'settings.usage.title': 'Utilizzo e limiti', + 'settings.usage.menuDesc': 'Costi, utilizzo dei token, budget e attività in background', 'settings.costDashboard.title': 'Cruscotto dei costi', - 'settings.costDashboard.desc': - "Spesa e bruciatura di token nell'arco di 7 giorni attraverso lo sciame, con ritmo di budget e suddivisione per modello.", 'settings.costDashboard.sevenDayCost': 'Costo giornaliero di 7 giorni', 'settings.costDashboard.sevenDayTokens': 'Utilizzo del token di 7 giorni', 'settings.costDashboard.totalSpend': 'Totale di 7 giorni', @@ -1966,6 +1972,9 @@ const messages: TranslationMap = { 'chat.editThreadTitle': 'Modifica titolo del thread', 'chat.hideSidebar': 'Nascondi barra laterale', 'chat.showSidebar': 'Mostra barra laterale', + 'chat.searchThreads': 'Cerca conversazioni', + 'layout.resizeSidebar': 'Ridimensiona barra laterale', + 'layout.showSidebar': 'Mostra barra laterale', 'chat.newThreadShortcut': 'Nuovo thread (/new)', 'chat.new': 'Nuovo', 'chat.failedToLoadMessages': 'Impossibile caricare i messaggi', @@ -3131,9 +3140,6 @@ const messages: TranslationMap = { 'pages.settings.aiSection.description': 'Provider di modelli linguistici, Ollama locale e voce (STT / TTS).', 'pages.settings.aiSection.title': 'AI', - 'pages.settings.composioSection.title': 'Composio', - 'pages.settings.composioSection.description': - 'Routing, trigger e cronologia per le integrazioni fornite da Composio.', 'settings.developerMenu.composio.title': 'Composio', 'settings.developerMenu.composio.desc': 'Modalità di routing, trigger di integrazione e archivio cronologico dei trigger.', @@ -4835,6 +4841,8 @@ const messages: TranslationMap = { 'walletSend.done': 'Fatto', 'walletSend.genericError': 'Impossibile completare il trasferimento. Riprova.', 'settings.taskSources.title': 'Fonti del compito', + 'settings.integrations.title': 'Integrazioni', + 'settings.integrations.menuDesc': 'Sorgenti di attività, routing Composio e trigger webhook', 'settings.taskSources.subtitle': "Estrai le attività dai tuoi strumenti sulla lavagna delle cose da fare dell'agente", 'settings.taskSources.description': diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 6d40152be..b70fb21b8 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -32,6 +32,9 @@ const messages: TranslationMap = { 'brain.subtitle': '지식 그래프, 메모리 소스 및 컨트롤.', 'brain.tabs.memory': '기억', 'brain.tabs.subconscious': '잠재의식', + 'brain.tabs.graph': '그래프', + 'brain.tabs.sources': '소스', + 'brain.tabs.sync': '동기화', 'brain.empty': '아직 브레인이 비어 있습니다 — 소스를 연결하여 메모리를 만들어 보세요.', 'brain.error': '브레인을 불러올 수 없습니다. 다시 시도해 주세요.', 'common.cancel': '취소', @@ -88,11 +91,11 @@ const messages: TranslationMap = { 'settings.groups.notifications': '알림', 'settings.groups.about': '정보', 'settings.assistant.personality': '개성', - 'settings.assistant.personalityDesc': '이름, 설명 및 SOUL.md 페르소나', + 'settings.personalityFace.title': '성격 및 얼굴', + 'settings.personalityFace.menuDesc': '어시스턴트의 성격을 조정하고 얼굴을 선택하세요', 'settings.assistant.voice': '음성', 'settings.assistant.voiceDesc': '음성-텍스트 및 텍스트-음성 설정', 'settings.assistant.faceMascot': '얼굴 / 마스코트', - 'settings.assistant.faceMascotDesc': '앱 전체에서 사용되는 마스코트 색상 선택', 'settings.assistant.backgroundActivity': '잠재의식', 'settings.assistant.backgroundActivityDesc': '어시스턴트가 백그라운드에서 얼마나 활발히 작동하는지 제어', @@ -167,6 +170,11 @@ const messages: TranslationMap = { 'settings.exitLocalSession': '로컬 세션 종료', 'settings.exitLocalSessionDesc': '로그인 화면으로 돌아가기', 'settings.language': '언어', + 'settings.navGroups.general': '일반', + 'settings.navGroups.assistant': '어시스턴트', + 'settings.navGroups.data': '데이터', + 'settings.navGroups.connections': '연결', + 'settings.navGroups.system': '시스템', 'settings.betaBuild': '베타 빌드 - v{version}', 'settings.languageDesc': '앱 인터페이스 표시 언어', 'settings.alerts': '알림', @@ -388,6 +396,8 @@ const messages: TranslationMap = { 'connections.tabs.mcp': 'MCP 서버', 'connections.tabs.skills': '스킬', 'connections.tabs.meetings': '미팅', + 'connections.groups.integrations': '통합', + 'connections.groups.intelligence': '인텔리전스', 'memory.title': '메모리', 'memory.search': '메모리 검색...', 'memory.noResults': '메모리를 찾을 수 없습니다', @@ -892,12 +902,9 @@ const messages: TranslationMap = { 'settings.about.connectionHelperCloud': '원격 코어에 연결되었습니다. BootCheck 또는 클라우드 모드 선택기에서 변경하세요.', 'settings.heartbeat.title': '하트비트 및 루프', - 'settings.heartbeat.desc': '백그라운드 예약 흐름을 제어하고 루프 맵을 검사합니다.', - 'settings.ledgerUsage.title': '사용량 원장', - 'settings.ledgerUsage.desc': '최근 크레딧 지출, 예산 계산 및 배경 API 읽기 예산.', + 'settings.usage.title': '사용량 및 한도', + 'settings.usage.menuDesc': '비용, 토큰 사용량, 예산 및 백그라운드 활동', 'settings.costDashboard.title': '비용 대시보드', - 'settings.costDashboard.desc': - '전체 스웜의 7일 지출과 토큰 사용량, 예산 속도 및 모델별 세부 내역입니다.', 'settings.costDashboard.sevenDayCost': '7일 일별 비용', 'settings.costDashboard.sevenDayTokens': '7일 토큰 사용량', 'settings.costDashboard.totalSpend': '7일 합계', @@ -1912,6 +1919,9 @@ const messages: TranslationMap = { 'chat.editThreadTitle': '스레드 제목 편집', 'chat.hideSidebar': '사이드바 숨기기', 'chat.showSidebar': '사이드바 표시', + 'chat.searchThreads': '대화 검색', + 'layout.resizeSidebar': '사이드바 크기 조정', + 'layout.showSidebar': '사이드바 표시', 'chat.newThreadShortcut': '새 스레드 (/new)', 'chat.new': '새로 만들기', 'chat.failedToLoadMessages': '메시지를 불러오지 못했습니다', @@ -3051,9 +3061,6 @@ const messages: TranslationMap = { 'pages.settings.ai.voiceDesc': '음성 설명', 'pages.settings.aiSection.description': '언어 모델 제공업체, 로컬 Ollama 및 음성(STT / TTS).', 'pages.settings.aiSection.title': 'AI', - 'pages.settings.composioSection.title': 'Composio', - 'pages.settings.composioSection.description': - 'Composio에서 제공하는 통합에 대한 라우팅, 트리거 및 기록입니다.', 'settings.developerMenu.composio.title': 'Composio', 'settings.developerMenu.composio.desc': '라우팅 모드, 통합 트리거 및 트리거 기록 아카이브.', 'pages.settings.features.desktopCompanion': '데스크탑 동반자', @@ -4700,6 +4707,8 @@ const messages: TranslationMap = { 'walletSend.done': '완료', 'walletSend.genericError': '전송을 완료할 수 없습니다. 다시 시도하세요.', 'settings.taskSources.title': '작업 소스', + 'settings.integrations.title': '통합', + 'settings.integrations.menuDesc': '작업 소스, Composio 라우팅 및 웹훅 트리거', 'settings.taskSources.subtitle': '도구의 작업을 에이전트 할 일 보드로 가져옵니다', 'settings.taskSources.description': 'GitHub, Notion, Linear, ClickUp에서 작업 항목을 수집하고 보강한 뒤 에이전트 할 일 보드로 라우팅합니다.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 564054654..0a7d05252 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -32,6 +32,9 @@ const messages: TranslationMap = { 'brain.subtitle': 'Twój graf wiedzy, źródła pamięci i ustawienia.', 'brain.tabs.memory': 'Pamięć', 'brain.tabs.subconscious': 'Podświadomość', + 'brain.tabs.graph': 'Graf', + 'brain.tabs.sources': 'Źródła', + 'brain.tabs.sync': 'Synchronizacja', 'brain.empty': 'Twój mózg jest na razie pusty — połącz źródło, aby zacząć budować pamięć.', 'brain.error': 'Nie udało się załadować Twojego mózgu. Spróbuj ponownie.', 'common.cancel': 'Anuluj', @@ -88,11 +91,11 @@ const messages: TranslationMap = { 'settings.groups.notifications': 'Powiadomienia', 'settings.groups.about': 'O aplikacji', 'settings.assistant.personality': 'Osobowość', - 'settings.assistant.personalityDesc': 'Nazwa, opis i persona SOUL.md', + 'settings.personalityFace.title': 'Osobowość i twarz', + 'settings.personalityFace.menuDesc': 'Dostosuj charakter asystenta i wybierz jego twarz', 'settings.assistant.voice': 'Głos', 'settings.assistant.voiceDesc': 'Ustawienia mowy na tekst i tekstu na mowę', 'settings.assistant.faceMascot': 'Twarz / Maskotka', - 'settings.assistant.faceMascotDesc': 'Wybierz kolor maskotki używany w aplikacji', 'settings.assistant.backgroundActivity': 'Podświadomość', 'settings.assistant.backgroundActivityDesc': 'Kontroluj, jak aktywnie asystent pracuje w tle', 'settings.assistant.screenAwareness': 'Świadomość ekranu', @@ -169,6 +172,11 @@ const messages: TranslationMap = { 'settings.exitLocalSession': 'Zakończ sesję lokalną', 'settings.exitLocalSessionDesc': 'Wróć do ekranu logowania', 'settings.language': 'Język', + 'settings.navGroups.general': 'Ogólne', + 'settings.navGroups.assistant': 'Asystent', + 'settings.navGroups.data': 'Dane', + 'settings.navGroups.connections': 'Połączenia', + 'settings.navGroups.system': 'System', 'settings.betaBuild': 'Wersja beta - v{version}', 'settings.languageDesc': 'Język wyświetlania interfejsu aplikacji', 'settings.alerts': 'Alerty', @@ -393,6 +401,8 @@ const messages: TranslationMap = { 'connections.tabs.mcp': 'Serwery MCP', 'connections.tabs.skills': 'Umiejętności', 'connections.tabs.meetings': 'Spotkania', + 'connections.groups.integrations': 'Integracje', + 'connections.groups.intelligence': 'Inteligencja', 'memory.title': 'Pamięć', 'memory.search': 'Szukaj w pamięci...', 'memory.noResults': 'Nie znaleziono wspomnień', @@ -911,13 +921,9 @@ const messages: TranslationMap = { 'settings.about.connectionHelperCloud': 'Połączono ze zdalnym rdzeniem. Zmień to w BootCheck lub w wyborze trybu chmury.', 'settings.heartbeat.title': 'Heartbeat i pętle', - 'settings.heartbeat.desc': 'Steruj częstotliwością harmonogramu tła i podglądaj mapę pętli.', - 'settings.ledgerUsage.title': 'Księga zużycia', - 'settings.ledgerUsage.desc': - 'Ostatnie wydatki kredytów, matematyka budżetu i budżet odczytów API w tle.', + 'settings.usage.title': 'Zużycie i limity', + 'settings.usage.menuDesc': 'Koszty, zużycie tokenów, budżety i aktywność w tle', 'settings.costDashboard.title': 'Panel kosztów', - 'settings.costDashboard.desc': - 'Wydatki i zużycie tokenów z 7 dni w całym roju, z tempem budżetu i rozbiciem per model.', 'settings.costDashboard.sevenDayCost': 'Dzienny koszt z 7 dni', 'settings.costDashboard.sevenDayTokens': 'Zużycie tokenów z 7 dni', 'settings.costDashboard.totalSpend': 'Suma z 7 dni', @@ -1955,6 +1961,9 @@ const messages: TranslationMap = { 'chat.editThreadTitle': 'Edytuj tytuł wątku', 'chat.hideSidebar': 'Ukryj panel boczny', 'chat.showSidebar': 'Pokaż panel boczny', + 'chat.searchThreads': 'Szukaj rozmów', + 'layout.resizeSidebar': 'Zmień rozmiar panelu bocznego', + 'layout.showSidebar': 'Pokaż panel boczny', 'chat.newThreadShortcut': 'Nowy wątek (/new)', 'chat.new': 'Nowy', 'chat.failedToLoadMessages': 'Nie udało się wczytać wiadomości', @@ -3123,9 +3132,6 @@ const messages: TranslationMap = { 'pages.settings.aiSection.description': 'Dostawcy modeli językowych, lokalna Ollama i głos (STT / TTS).', 'pages.settings.aiSection.title': 'AI', - 'pages.settings.composioSection.title': 'Composio', - 'pages.settings.composioSection.description': - 'Trasowanie, wyzwalacze i historia integracji obsługiwanych przez Composio.', 'settings.developerMenu.composio.title': 'Composio', 'settings.developerMenu.composio.desc': 'Tryb trasowania, wyzwalacze integracji i archiwum historii wyzwalaczy.', @@ -4826,6 +4832,8 @@ const messages: TranslationMap = { 'walletSend.done': 'Gotowe', 'walletSend.genericError': 'Nie udało się zrealizować transferu. Spróbuj ponownie.', 'settings.taskSources.title': 'Źródła zadań', + 'settings.integrations.title': 'Integracje', + 'settings.integrations.menuDesc': 'Źródła zadań, routing Composio i wyzwalacze webhooków', 'settings.taskSources.subtitle': 'Pobieraj zadania z narzędzi na tablicę zadań agenta', 'settings.taskSources.description': 'Zbieraj elementy pracy z GitHub, Notion, Linear i ClickUp, wzbogacaj je i kieruj na tablicę zadań agenta.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 3e0f76aae..fbac0a4ce 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -32,6 +32,9 @@ const messages: TranslationMap = { 'brain.subtitle': 'Seu grafo de conhecimento, fontes de memória e controles.', 'brain.tabs.memory': 'Memória', 'brain.tabs.subconscious': 'Subconsciente', + 'brain.tabs.graph': 'Gráfico', + 'brain.tabs.sources': 'Fontes', + 'brain.tabs.sync': 'Sincronização', 'brain.empty': 'Seu cérebro está vazio por enquanto — conecte uma fonte para começar a construir a memória.', 'brain.error': 'Não foi possível carregar seu cérebro. Tente novamente.', @@ -89,11 +92,11 @@ const messages: TranslationMap = { 'settings.groups.notifications': 'Notificações', 'settings.groups.about': 'Sobre', 'settings.assistant.personality': 'Personalidade', - 'settings.assistant.personalityDesc': 'Nome, descrição e persona SOUL.md', + 'settings.personalityFace.title': 'Personalidade e rosto', + 'settings.personalityFace.menuDesc': 'Ajuste o caráter do seu assistente e escolha seu rosto', 'settings.assistant.voice': 'Voz', 'settings.assistant.voiceDesc': 'Configurações de fala para texto e texto para fala', 'settings.assistant.faceMascot': 'Rosto / Mascote', - 'settings.assistant.faceMascotDesc': 'Escolha a cor do mascote usada no aplicativo', 'settings.assistant.backgroundActivity': 'Subconsciente', 'settings.assistant.backgroundActivityDesc': 'Controle o quão ativamente seu assistente trabalha em segundo plano', @@ -173,6 +176,11 @@ const messages: TranslationMap = { 'settings.exitLocalSession': 'Sair da sessão local', 'settings.exitLocalSessionDesc': 'Retornar à tela de login', 'settings.language': 'Idioma', + 'settings.navGroups.general': 'Geral', + 'settings.navGroups.assistant': 'Assistente', + 'settings.navGroups.data': 'Dados', + 'settings.navGroups.connections': 'Conexões', + 'settings.navGroups.system': 'Sistema', 'settings.betaBuild': 'Versão beta - v{version}', 'settings.languageDesc': 'Idioma de exibição da interface do app', 'settings.alerts': 'Alertas', @@ -400,6 +408,8 @@ const messages: TranslationMap = { 'connections.tabs.mcp': 'Servidores MCP', 'connections.tabs.skills': 'Habilidades', 'connections.tabs.meetings': 'Reuniões', + 'connections.groups.integrations': 'Integrações', + 'connections.groups.intelligence': 'Inteligência', 'memory.title': 'Memória', 'memory.search': 'Pesquisar memórias...', 'memory.noResults': 'Nenhuma memória encontrada', @@ -922,14 +932,9 @@ const messages: TranslationMap = { 'settings.about.connectionHelperCloud': 'Conectado a um núcleo remoto. Altere isso no BootCheck ou no seletor de modo de nuvem.', 'settings.heartbeat.title': 'Heartbeat e loops', - 'settings.heartbeat.desc': - 'Controle as cadências de agendamento em segundo plano e inspecione o mapa de loop.', - 'settings.ledgerUsage.title': 'Razão de uso', - 'settings.ledgerUsage.desc': - 'Gastos recentes de crédito, matemática do orçamento e leitura do orçamento de fundo API.', + 'settings.usage.title': 'Uso e limites', + 'settings.usage.menuDesc': 'Custos, uso de tokens, orçamentos e atividade em segundo plano', 'settings.costDashboard.title': 'Painel de custos', - 'settings.costDashboard.desc': - 'Gastos de 7 dias e queima de tokens em toda a rede, com ritmo de orçamento e detalhamento por modelo.', 'settings.costDashboard.sevenDayCost': 'Custo diário de 7 dias', 'settings.costDashboard.sevenDayTokens': 'Uso de token de 7 dias', 'settings.costDashboard.totalSpend': 'total de 7 dias', @@ -1975,6 +1980,9 @@ const messages: TranslationMap = { 'chat.editThreadTitle': 'Editar título da conversa', 'chat.hideSidebar': 'Ocultar barra lateral', 'chat.showSidebar': 'Mostrar barra lateral', + 'chat.searchThreads': 'Pesquisar conversas', + 'layout.resizeSidebar': 'Redimensionar barra lateral', + 'layout.showSidebar': 'Mostrar barra lateral', 'chat.newThreadShortcut': 'Nova conversa (/new)', 'chat.new': 'Nova', 'chat.failedToLoadMessages': 'Falha ao carregar mensagens', @@ -3135,9 +3143,6 @@ const messages: TranslationMap = { 'pages.settings.aiSection.description': 'Provedores de modelos de linguagem, Ollama local e voz (STT / TTS).', 'pages.settings.aiSection.title': 'IA', - 'pages.settings.composioSection.title': 'Composio', - 'pages.settings.composioSection.description': - 'Roteamento, gatilhos e histórico para integrações desenvolvidas por Composio.', 'settings.developerMenu.composio.title': 'Composio', 'settings.developerMenu.composio.desc': 'Modo de roteamento, gatilhos de integração e arquivo de histórico de gatilhos.', @@ -4832,6 +4837,8 @@ const messages: TranslationMap = { 'walletSend.done': 'Concluído', 'walletSend.genericError': 'Não foi possível concluir a transferência. Tente novamente.', 'settings.taskSources.title': 'Fontes da Tarefa', + 'settings.integrations.title': 'Integrações', + 'settings.integrations.menuDesc': 'Fontes de tarefas, roteamento Composio e gatilhos de webhooks', 'settings.taskSources.subtitle': 'Puxe tarefas de suas ferramentas para o quadro de tarefas do agente', 'settings.taskSources.description': diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index a8d4ed7e5..b617e3e25 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -32,6 +32,9 @@ const messages: TranslationMap = { 'brain.subtitle': 'Ваш граф знаний, источники памяти и элементы управления.', 'brain.tabs.memory': 'Память', 'brain.tabs.subconscious': 'Подсознание', + 'brain.tabs.graph': 'Граф', + 'brain.tabs.sources': 'Источники', + 'brain.tabs.sync': 'Синхронизация', 'brain.empty': 'Ваш мозг пока пуст — подключите источник, чтобы начать формировать память.', 'brain.error': 'Не удалось загрузить ваш мозг. Пожалуйста, попробуйте ещё раз.', 'common.cancel': 'Отмена', @@ -88,11 +91,11 @@ const messages: TranslationMap = { 'settings.groups.notifications': 'Уведомления', 'settings.groups.about': 'О приложении', 'settings.assistant.personality': 'Личность', - 'settings.assistant.personalityDesc': 'Имя, описание и персона SOUL.md', + 'settings.personalityFace.title': 'Личность и лицо', + 'settings.personalityFace.menuDesc': 'Настройте характер ассистента и выберите его лицо', 'settings.assistant.voice': 'Голос', 'settings.assistant.voiceDesc': 'Настройки распознавания и синтеза речи', 'settings.assistant.faceMascot': 'Лицо / Маскот', - 'settings.assistant.faceMascotDesc': 'Выберите цвет маскота в приложении', 'settings.assistant.backgroundActivity': 'Подсознание', 'settings.assistant.backgroundActivityDesc': 'Управление тем, насколько активно ассистент работает в фоне', @@ -172,6 +175,11 @@ const messages: TranslationMap = { 'settings.exitLocalSession': 'Выход из локального сеанса', 'settings.exitLocalSessionDesc': 'Возврат к экрану входа в систему', 'settings.language': 'Язык', + 'settings.navGroups.general': 'Общие', + 'settings.navGroups.assistant': 'Ассистент', + 'settings.navGroups.data': 'Данные', + 'settings.navGroups.connections': 'Подключения', + 'settings.navGroups.system': 'Система', 'settings.betaBuild': 'Бета-сборка — v{version}', 'settings.languageDesc': 'Язык отображения интерфейса', 'settings.alerts': 'Оповещения', @@ -393,6 +401,8 @@ const messages: TranslationMap = { 'connections.tabs.mcp': 'MCP-серверы', 'connections.tabs.skills': 'Навыки', 'connections.tabs.meetings': 'Встречи', + 'connections.groups.integrations': 'Интеграции', + 'connections.groups.intelligence': 'Интеллект', 'memory.title': 'Память', 'memory.search': 'Поиск воспоминаний...', 'memory.noResults': 'Воспоминания не найдены', @@ -907,13 +917,9 @@ const messages: TranslationMap = { 'settings.about.connectionHelperCloud': 'Подключен к удаленному ядру. Измените это в BootCheck или в средстве выбора облачного режима.', 'settings.heartbeat.title': 'Heartbeat и циклы', - 'settings.heartbeat.desc': 'Управляйте частотой фонового планирования и проверяйте карту циклов.', - 'settings.ledgerUsage.title': 'Журнал использования', - 'settings.ledgerUsage.desc': - 'Недавние расходы по кредитам, математические расчеты бюджета и предыстория. API читает бюджет.', + 'settings.usage.title': 'Использование и лимиты', + 'settings.usage.menuDesc': 'Расходы, использование токенов, бюджеты и фоновая активность', 'settings.costDashboard.title': 'Панель затрат', - 'settings.costDashboard.desc': - '7-дневные расходы и сжигание токенов по всему множеству, с указанием темпа бюджета и разбивки по моделям.', 'settings.costDashboard.sevenDayCost': '7-дневная ежедневная стоимость', 'settings.costDashboard.sevenDayTokens': '7-дневное использование токена', 'settings.costDashboard.totalSpend': 'всего 7 дней', @@ -1950,6 +1956,9 @@ const messages: TranslationMap = { 'chat.editThreadTitle': 'Изменить название ветки', 'chat.hideSidebar': 'Скрыть боковую панель', 'chat.showSidebar': 'Показать боковую панель', + 'chat.searchThreads': 'Поиск бесед', + 'layout.resizeSidebar': 'Изменить размер боковой панели', + 'layout.showSidebar': 'Показать боковую панель', 'chat.newThreadShortcut': 'Новый чат (/new)', 'chat.new': 'Новый', 'chat.failedToLoadMessages': 'Не удалось загрузить сообщения', @@ -3105,9 +3114,6 @@ const messages: TranslationMap = { 'pages.settings.ai.voiceDesc': 'Описание голоса', 'pages.settings.aiSection.description': 'Языковые модели, локальный Ollama и голос (STT / TTS).', 'pages.settings.aiSection.title': 'ИИ', - 'pages.settings.composioSection.title': 'Composio', - 'pages.settings.composioSection.description': - 'Маршрутизация, триггеры и история интеграций на базе Composio.', 'settings.developerMenu.composio.title': 'Composio', 'settings.developerMenu.composio.desc': 'Режим маршрутизации, триггеры интеграции и архив истории триггеров.', @@ -4797,6 +4803,8 @@ const messages: TranslationMap = { 'walletSend.done': 'Готово', 'walletSend.genericError': 'Не удалось выполнить перевод. Повторите попытку.', 'settings.taskSources.title': 'Источники задач', + 'settings.integrations.title': 'Интеграции', + 'settings.integrations.menuDesc': 'Источники задач, маршрутизация Composio и веб-хук триггеры', 'settings.taskSources.subtitle': 'Переносите задачи из своих инструментов на доску задач агента.', 'settings.taskSources.description': 'Собирайте рабочие элементы из GitHub, Notion, Linear и ClickUp, обогащайте их и направляйте на доску задач агента.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index c48f8a4a5..0173bf0bc 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -32,6 +32,9 @@ const messages: TranslationMap = { 'brain.subtitle': '你的知识图谱、记忆来源与控制项。', 'brain.tabs.memory': '记忆', 'brain.tabs.subconscious': '潜意识', + 'brain.tabs.graph': '图谱', + 'brain.tabs.sources': '来源', + 'brain.tabs.sync': '同步', 'brain.empty': '你的大脑暂时是空的——连接一个来源即可开始构建记忆。', 'brain.error': '无法加载你的大脑,请重试。', 'common.cancel': '取消', @@ -87,11 +90,11 @@ const messages: TranslationMap = { 'settings.groups.notifications': '通知', 'settings.groups.about': '关于', 'settings.assistant.personality': '个性', - 'settings.assistant.personalityDesc': '名称、描述和 SOUL.md 人设', + 'settings.personalityFace.title': '个性与形象', + 'settings.personalityFace.menuDesc': '调整助手的性格并选择它的形象', 'settings.assistant.voice': '语音', 'settings.assistant.voiceDesc': '语音转文字和文字转语音设置', 'settings.assistant.faceMascot': '面孔 / 吉祥物', - 'settings.assistant.faceMascotDesc': '选择应用中使用的吉祥物颜色', 'settings.assistant.backgroundActivity': '潜意识', 'settings.assistant.backgroundActivityDesc': '控制助手在后台工作的活跃程度', 'settings.assistant.screenAwareness': '屏幕感知', @@ -163,6 +166,11 @@ const messages: TranslationMap = { 'settings.exitLocalSession': '退出本地会话', 'settings.exitLocalSessionDesc': '返回登录页面', 'settings.language': '语言', + 'settings.navGroups.general': '通用', + 'settings.navGroups.assistant': '助手', + 'settings.navGroups.data': '数据', + 'settings.navGroups.connections': '连接', + 'settings.navGroups.system': '系统', 'settings.betaBuild': '测试版本 - v{version}', 'settings.languageDesc': '应用界面显示语言', 'settings.alerts': '通知', @@ -371,6 +379,8 @@ const messages: TranslationMap = { 'connections.tabs.mcp': 'MCP 服务器', 'connections.tabs.skills': '技能', 'connections.tabs.meetings': '会议', + 'connections.groups.integrations': '集成', + 'connections.groups.intelligence': '智能', 'memory.title': '记忆', 'memory.search': '搜索记忆...', 'memory.noResults': '未找到记忆', @@ -850,11 +860,9 @@ const messages: TranslationMap = { '应用启动时由 Tauri shell 在进程内启动。端口会在启动时选择,因此此 URL 每次启动都可能变化。', 'settings.about.connectionHelperCloud': '已连接到远程核心。可在 BootCheck 或云模式选择器中更改。', 'settings.heartbeat.title': '心跳和循环', - 'settings.heartbeat.desc': '控制后台调度节奏并检查循环图。', - 'settings.ledgerUsage.title': '使用账本', - 'settings.ledgerUsage.desc': '最近的信贷支出、预算数学和背景 API 阅读预算。', + 'settings.usage.title': '用量与限制', + 'settings.usage.menuDesc': '费用、token 使用量、预算和后台活动', 'settings.costDashboard.title': '成本看板', - 'settings.costDashboard.desc': '查看集群过去 7 天的支出和 token 消耗,包括预算节奏和按模型拆分。', 'settings.costDashboard.sevenDayCost': '7 天每日成本', 'settings.costDashboard.sevenDayTokens': '7 天 token 用量', 'settings.costDashboard.totalSpend': '7 天总计', @@ -1826,6 +1834,9 @@ const messages: TranslationMap = { 'chat.editThreadTitle': '编辑会话标题', 'chat.hideSidebar': '隐藏侧边栏', 'chat.showSidebar': '显示侧边栏', + 'chat.searchThreads': '搜索对话', + 'layout.resizeSidebar': '调整侧边栏大小', + 'layout.showSidebar': '显示侧边栏', 'chat.newThreadShortcut': '新建对话', 'chat.new': '新建', 'chat.failedToLoadMessages': '加载消息失败', @@ -2926,9 +2937,6 @@ const messages: TranslationMap = { 'pages.settings.ai.voiceDesc': '配置语音输入和输出', 'pages.settings.aiSection.description': '语言模型提供商、本地 Ollama 以及语音(STT / TTS)。', 'pages.settings.aiSection.title': 'AI', - 'pages.settings.composioSection.title': 'Composio', - 'pages.settings.composioSection.description': - '由 Composio 提供支持的集成的路由、触发器和历史记录。', 'settings.developerMenu.composio.title': 'Composio', 'settings.developerMenu.composio.desc': '路由模式、集成触发器和触发器历史存档。', 'pages.settings.features.desktopCompanion': '桌面伴侣', @@ -4513,6 +4521,8 @@ const messages: TranslationMap = { 'walletSend.done': '完成', 'walletSend.genericError': '无法完成转账。请重试。', 'settings.taskSources.title': '任务来源', + 'settings.integrations.title': '集成', + 'settings.integrations.menuDesc': '任务来源、Composio 路由和 Webhook 触发器', 'settings.taskSources.subtitle': '从你的工具拉取任务到智能体待办板', 'settings.taskSources.description': '从 GitHub、Notion、Linear 和 ClickUp 收集工作项,补充信息后路由到智能体待办板。', diff --git a/app/src/pages/Brain.tsx b/app/src/pages/Brain.tsx index d1e4a2efe..b084a3808 100644 --- a/app/src/pages/Brain.tsx +++ b/app/src/pages/Brain.tsx @@ -5,7 +5,8 @@ * - **Memory**: knowledge graph, tree status, and connected sources. * - **Subconscious**: background thinking engine controls. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab'; import { MemoryControls } from '../components/intelligence/MemoryControls'; @@ -13,7 +14,12 @@ import { MemoryGraph } from '../components/intelligence/MemoryGraph'; import { MemorySourcesRegistry } from '../components/intelligence/MemorySourcesRegistry'; import { MemoryTreeStatusPanel } from '../components/intelligence/MemoryTreeStatusPanel'; import { ToastContainer } from '../components/intelligence/Toast'; -import PillTabBar from '../components/PillTabBar'; +import TwoPanelLayout from '../components/layout/TwoPanelLayout'; +import TwoPaneNav from '../components/layout/TwoPaneNav'; +import { SettingsLayoutProvider } from '../components/settings/layout/SettingsLayoutContext'; +import AnalysisViewsPanel from '../components/settings/panels/AnalysisViewsPanel'; +import MemoryDataPanel from '../components/settings/panels/MemoryDataPanel'; +import MemoryDebugPanel from '../components/settings/panels/MemoryDebugPanel'; import BetaBanner from '../components/ui/BetaBanner'; import { useSubconscious } from '../hooks/useSubconscious'; import { useT } from '../lib/i18n/I18nContext'; @@ -23,12 +29,62 @@ import { type GraphMode, memoryTreeGraphExport, } from '../utils/tauriCommands'; +import Intelligence from './Intelligence'; -type BrainTab = 'memory' | 'subconscious'; +type BrainTab = + | 'graph' + | 'sources' + | 'sync' + | 'intelligence' + | 'memory-data' + | 'memory-debug' + | 'analysis-views' + | 'subconscious'; + +/** Tabs that render a relocated settings panel (Knowledge & Memory group). */ +const KNOWLEDGE_TABS: ReadonlySet = new Set([ + 'intelligence', + 'memory-data', + 'memory-debug', + 'analysis-views', +]); + +/** Small inline icon helper for the Brain sidebar nav. */ +const navIcon = (d: string) => ( + + + +); + +const BRAIN_TABS: readonly BrainTab[] = [ + 'graph', + 'sources', + 'sync', + 'intelligence', + 'memory-data', + 'memory-debug', + 'analysis-views', + 'subconscious', +]; export default function Brain() { const { t } = useT(); - const [activeTab, setActiveTab] = useState('memory'); + const location = useLocation(); + const navigate = useNavigate(); + // Tab is reflected in `?tab=` so deep links (and the redirected old settings + // routes) land on the right sub-page. + const activeTab = useMemo(() => { + const raw = new URLSearchParams(location.search).get('tab'); + return (BRAIN_TABS as readonly string[]).includes(raw ?? '') ? (raw as BrainTab) : 'graph'; + }, [location.search]); + const setActiveTab = useCallback( + (tab: BrainTab) => { + const params = new URLSearchParams(location.search); + params.set('tab', tab); + navigate({ pathname: location.pathname, search: `?${params.toString()}` }); + }, + [location.pathname, location.search, navigate] + ); const [graph, setGraph] = useState(null); const [error, setError] = useState(null); const [mode, setMode] = useState('tree'); @@ -81,76 +137,184 @@ export default function Brain() { 'rounded-lg border border-stone-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900'; return ( -
-
-
-

- {t('nav.brain')} -

-

{t('brain.subtitle')}

-
- - - selected={activeTab} - onChange={setActiveTab} - items={[ - { value: 'memory', label: t('brain.tabs.memory') }, - { value: 'subconscious', label: t('brain.tabs.subconscious') }, - ]} - /> - - {activeTab === 'memory' && ( -
- - - {graph ? ( - - ) : error ? ( -
- {t('brain.error')} +
+ setActiveTab(value as BrainTab)} + groups={[ + { + label: t('brain.tabs.memory'), + items: [ + { + value: 'graph', + label: t('brain.tabs.graph'), + icon: navIcon( + 'M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z' + ), + }, + { + value: 'sources', + label: t('brain.tabs.sources'), + icon: navIcon( + 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4' + ), + }, + { + value: 'sync', + label: t('brain.tabs.sync'), + icon: navIcon( + 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' + ), + }, + ], + }, + { + label: t('settings.devGroups.knowledgeMemory'), + items: [ + { + value: 'intelligence', + label: t('settings.developerMenu.intelligence.title'), + icon: navIcon( + 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z' + ), + }, + { + value: 'memory-data', + label: t('devOptions.memoryInspection'), + icon: navIcon( + 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4' + ), + }, + { + value: 'memory-debug', + label: t('devOptions.debugPanels'), + icon: navIcon('M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4'), + }, + { + value: 'analysis-views', + label: t('settings.analysisViews.title'), + icon: navIcon( + 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' + ), + }, + ], + }, + { + items: [ + { + value: 'subconscious', + label: t('brain.tabs.subconscious'), + icon: navIcon( + 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z' + ), + }, + ], + }, + ]} + header={ +
+

+ {t('nav.brain')} +

+

+ {t('brain.subtitle')} +

- ) : null} + } + /> + }> +
+
+ {activeTab === 'graph' && ( +
+ -
-
- + {graph ? ( + + ) : error ? ( +
+ {t('brain.error')} +
+ ) : null}
- -
-
- )} + )} - {activeTab === 'subconscious' && ( -
- -
- -
+ {activeTab === 'sources' && ( +
+ +
+ )} + + {activeTab === 'sync' && ( +
+
+ +
+
+ )} + + {/* Knowledge & Memory panels relocated from Settings. Wrapped in the + settings two-pane context so their headers hide the back button + (the Brain sidebar provides navigation here). */} + {KNOWLEDGE_TABS.has(activeTab) && ( +
+ + {/* Use a distinct tab query key so the embedded Intelligence + panel's internal tab switches don't overwrite Brain's own + `?tab=intelligence` and unmount it. */} + {activeTab === 'intelligence' && } + {activeTab === 'memory-data' && } + {activeTab === 'memory-debug' && } + {activeTab === 'analysis-views' && } + +
+ )} + + {activeTab === 'subconscious' && ( +
+ +
+ +
+
+ )}
- )} -
+
+
diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index fb36ee268..790f5caab 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -11,6 +11,7 @@ import ChatComposer from '../components/chat/ChatComposer'; import ChatFilesChip from '../components/chat/ChatFilesChip'; import ComposerTokenStats from '../components/chat/ComposerTokenStats'; import { ConfirmationModal } from '../components/intelligence/ConfirmationModal'; +import TwoPanelLayout, { useTwoPanelLayout } from '../components/layout/TwoPanelLayout'; import PillTabBar from '../components/PillTabBar'; import UpsellBanner from '../components/upsell/UpsellBanner'; import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDismissState'; @@ -58,7 +59,6 @@ import { markThreadInferenceActive, persistReaction, setSelectedThread, - setThreadSidebarVisible, THREAD_NOT_FOUND_MESSAGE, updateThreadTitle, } from '../store/threadSlice'; @@ -198,14 +198,9 @@ const Conversations = ({ const dispatch = useAppDispatch(); const navigate = useNavigate(); const location = useLocation(); - const { - threads, - selectedThreadId, - threadSidebarVisible = false, - messages, - isLoadingMessages, - messagesError, - } = useAppSelector(state => state.thread); + const { threads, selectedThreadId, messages, isLoadingMessages, messagesError } = useAppSelector( + state => state.thread + ); // Optional-chain + default: narrow test stores may omit `activeThreadIds`. const activeThreadIds = useAppSelector( state => state.thread.activeThreadIds ?? EMPTY_ACTIVE_THREADS @@ -236,6 +231,7 @@ const Conversations = ({ const [voiceStatus, setVoiceStatus] = useState(null); const [isPlayingReply, setIsPlayingReply] = useState(false); const [selectedLabel, setSelectedLabel] = useState(GENERAL_TAB_VALUE); + const [threadSearch, setThreadSearch] = useState(''); const [inlineSuggestionValue, setInlineSuggestionValue] = useState(''); const [sendError, setSendError] = useState(null); const [attachError, setAttachError] = useState(null); @@ -1411,6 +1407,14 @@ const Conversations = ({ ); }, [filteredThreads]); + // Free-text search over the thread sidebar — filters the visible list by + // title (mirrors the settings sidebar search). + const visibleThreads = useMemo(() => { + const q = threadSearch.trim().toLowerCase(); + if (!q) return sortedThreads; + return sortedThreads.filter(thread => (thread.title ?? '').toLowerCase().includes(q)); + }, [sortedThreads, threadSearch]); + // Fixed bucket set so categories don't disappear when empty and the active // filter state remains unambiguous regardless of what threads exist. const labelTabs = [ @@ -1422,7 +1426,11 @@ const Conversations = ({ labelTabs.find(tab => tab.value === selectedLabel)?.label ?? selectedLabel; const isSidebar = variant === 'sidebar'; - const effectiveShowSidebar = threadSidebarVisible; + // Chat thread sidebar visibility/width are owned by the reusable + // TwoPanelLayout (persisted per-user in the `layout` slice under id `chat`). + // The hook lets this header's hamburger toggle the same persisted state. + const { sidebarVisible: chatSidebarVisible, toggleSidebar: toggleChatSidebar } = + useTwoPanelLayout('chat', { sidebarVisible: false }); // Stable title resolver used by both the sidebar thread list and the header. const resolveThreadDisplayTitle = (threadId: string | null): string => { @@ -1450,105 +1458,138 @@ const Conversations = ({ : { id: parentId, title: t('chat.parentThread') }; }, [threads, selectedThreadId, t]); - return ( -
- {/* Thread sidebar — only shown in page mode (when Conversations itself - is a top-level route, not embedded as a sidebar in another page). - During welcome lockdown the sidebar is always open (effectiveShowSidebar - is clamped to true) so the single onboarding thread is always visible. */} - {!isSidebar && effectiveShowSidebar && ( -
-
- + {/* Thread search — flush full-width input, mirrors the settings search. */} +
+ + + -
-
- {sortedThreads.length === 0 ? ( -

- {t('chat.noLabelThreads').replace('{label}', selectedLabelDisplay)} -

- ) : ( - sortedThreads.map(thread => ( -
{ - dispatch(setSelectedThread(thread.id)); - void dispatch(loadThreadMessages(thread.id)); - }} - onKeyDown={e => { - if (e.target !== e.currentTarget) return; - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - dispatch(setSelectedThread(thread.id)); - void dispatch(loadThreadMessages(thread.id)); - } - }} - className={`w-full text-left px-3 py-1.5 border-b border-stone-100/60 dark:border-neutral-800/60 transition-colors group cursor-pointer ${ + + + setThreadSearch(e.target.value)} + onKeyDown={e => { + if (e.key === 'Escape' && threadSearch) { + e.preventDefault(); + setThreadSearch(''); + } + }} + placeholder={t('chat.searchThreads')} + aria-label={t('chat.searchThreads')} + data-testid="chat-thread-search-input" + className="w-full border-0 bg-transparent py-2.5 pl-10 pr-10 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-0 dark:text-neutral-100 dark:placeholder:text-neutral-500" + /> + {threadSearch && ( + + )} +
+
+ +
+
+ {visibleThreads.length === 0 ? ( +

+ {t('chat.noLabelThreads').replace('{label}', selectedLabelDisplay)} +

+ ) : ( + visibleThreads.map(thread => ( +
{ + dispatch(setSelectedThread(thread.id)); + void dispatch(loadThreadMessages(thread.id)); + }} + onKeyDown={e => { + if (e.target !== e.currentTarget) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + dispatch(setSelectedThread(thread.id)); + void dispatch(loadThreadMessages(thread.id)); + } + }} + className={`w-full text-left px-3 py-1.5 border-b border-stone-100/60 dark:border-neutral-800/60 transition-colors group cursor-pointer ${ + selectedThreadId === thread.id + ? 'bg-primary-50 dark:bg-primary-900/30 border-l-2 border-l-primary-500' + : 'hover:bg-stone-50 dark:hover:bg-neutral-800/60' + }`}> +
+

-

-

- {resolveThreadDisplayTitle(thread.id)} -

- -
- {/*
+ {resolveThreadDisplayTitle(thread.id)} +

+ +
+ {/*
{formatRelativeTime(thread.lastMessageAt)} @@ -1558,947 +1599,971 @@ const Conversations = ({ )}
*/} -
- )) - )} -
-
- )} +
+ )) + )} +
+
+ ); - {/* Main chat area */} -
- {/* Chat header — only shown in page mode; the sidebar embed uses the + // Main chat area (right pane): header, message list, composer. + const mainPanel = ( +
+ {/* Chat header — only shown in page mode; the sidebar embed uses the parent page's chrome instead. Hidden entirely during welcome lockdown (#883) so the onboarding chat is just the conversation with no chrome around it. */} - {!isSidebar && ( -
+ {!isSidebar && ( +
+ +
+ {selectedThreadParent ? ( + + ) : null} + {editingTitle ? ( + setEditTitleValue(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + handleCommitTitle(); + } else if (e.key === 'Escape') { + setEditingTitle(false); + } + }} + onBlur={() => { + if (ignoreNextTitleBlurRef.current) { + ignoreNextTitleBlurRef.current = false; + return; + } + handleCommitTitle(); + }} + aria-label={t('chat.editThreadTitle')} + className="h-5 text-sm font-medium text-stone-700 dark:text-neutral-200 bg-transparent border-b border-primary-400 outline-none w-full min-w-0 leading-none py-0" + autoFocus + /> + ) : ( +
+

+ {resolveThreadDisplayTitle(selectedThreadId)} +

+ {selectedThreadId && ( + + )} +
+ )} + {resolvedModel && ( + + {resolvedModel} + + )} +
+ <> +
+ + +
+ {(selectedThreadId ?? firstActiveThreadId) && ( + + )} -
- {selectedThreadParent ? ( - - ) : null} - {editingTitle ? ( - setEditTitleValue(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') { - e.preventDefault(); - handleCommitTitle(); - } else if (e.key === 'Escape') { - setEditingTitle(false); - } - }} - onBlur={() => { - if (ignoreNextTitleBlurRef.current) { - ignoreNextTitleBlurRef.current = false; - return; - } - handleCommitTitle(); - }} - aria-label={t('chat.editThreadTitle')} - className="h-5 text-sm font-medium text-stone-700 dark:text-neutral-200 bg-transparent border-b border-primary-400 outline-none w-full min-w-0 leading-none py-0" - autoFocus + +
+ )} +
+ {isLoadingMessages ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
- ) : ( -
-

- {resolveThreadDisplayTitle(selectedThreadId)} -

- {selectedThreadId && ( - - )} -
- )} - {resolvedModel && ( - - {resolvedModel} - - )} -
- <> -
- -
- {(selectedThreadId ?? firstActiveThreadId) && ( - - )} - - + ))}
- )} -
- {isLoadingMessages ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( -
+ ) : messagesError ? ( +
+ + + +

+ {t('chat.failedToLoadMessages')} +

+

+ {messagesError} +

+ +
+ ) : hasVisibleMessages || hasTaskBoard ? ( +
+ {selectedTaskBoard && hasTaskBoard && ( + { + void handleMoveTaskCard(card, status); + }} + onUpdateCard={(card, nextCard) => { + void handleUpdateTaskCard(card, nextCard); + }} + onDecidePlan={(card, approve) => { + void runDecidePlan({ + threadId: selectedThreadId, + card, + approve, + dispatch, + notify: setSendAdvisory, + t, + }); + }} + onViewSession={card => { + if (!card.sessionThreadId) return; + // Navigation only — do NOT mark the thread active. activeThreadId + // tracks a true in-flight turn (set on send, cleared on + // done/error). A completed session never emits that lifecycle + // event, so forcing it active would wedge the composer. + dispatch(setSelectedThread(card.sessionThreadId)); + void dispatch(loadThreadMessages(card.sessionThreadId)); + }} + /> + )} + {visibleMessages.map(msg => { + const isAgentTextMode = msg.sender === 'agent' && agentMessageViewMode === 'text'; + return ( +
+ {shouldRenderTimelineBeforeLatestAgentMessage && + latestVisibleAgentMessage?.id === msg.id && ( + setOpenSubagentTaskId(sub.taskId)} + /> + )}
-
- ))} -
- ) : messagesError ? ( -
- - - -

- {t('chat.failedToLoadMessages')} -

-

- {messagesError} -

- -
- ) : hasVisibleMessages || hasTaskBoard ? ( -
- {selectedTaskBoard && hasTaskBoard && ( - { - void handleMoveTaskCard(card, status); - }} - onUpdateCard={(card, nextCard) => { - void handleUpdateTaskCard(card, nextCard); - }} - onDecidePlan={(card, approve) => { - void runDecidePlan({ - threadId: selectedThreadId, - card, - approve, - dispatch, - notify: setSendAdvisory, - t, - }); - }} - onViewSession={card => { - if (!card.sessionThreadId) return; - // Navigation only — do NOT mark the thread active. activeThreadId - // tracks a true in-flight turn (set on send, cleared on - // done/error). A completed session never emits that lifecycle - // event, so forcing it active would wedge the composer. - dispatch(setSelectedThread(card.sessionThreadId)); - void dispatch(loadThreadMessages(card.sessionThreadId)); - }} - /> - )} - {visibleMessages.map(msg => { - const isAgentTextMode = msg.sender === 'agent' && agentMessageViewMode === 'text'; - return ( -
- {shouldRenderTimelineBeforeLatestAgentMessage && - latestVisibleAgentMessage?.id === msg.id && ( - setOpenSubagentTaskId(sub.taskId)} - /> - )} + className={`group/msg flex ${msg.sender === 'user' ? 'justify-end' : 'justify-start'}`}>
-
- {msg.sender === 'agent' ? ( -
- {agentMessageViewMode === 'text' ? ( - - ) : ( - splitAgentMessageIntoBubbles(msg.content).map( - (segment, index, parts) => { - const position: AgentBubblePosition = - parts.length === 1 - ? 'single' - : index === 0 - ? 'first' - : index === parts.length - 1 - ? 'last' - : 'middle'; - - return ( - - ); - } - ) - )} - {(() => { - const raw = msg.extraMetadata?.citations; - if (!Array.isArray(raw)) return null; - const citations = raw.filter( - (item): item is MessageCitation => - typeof item === 'object' && - item !== null && - typeof (item as MessageCitation).id === 'string' && - typeof (item as MessageCitation).key === 'string' && - typeof (item as MessageCitation).snippet === 'string' && - typeof (item as MessageCitation).timestamp === 'string' - ); - if (citations.length === 0) return null; - return ; - })()} - {shouldRenderTimelineBeforeLatestAgentMessage && - latestVisibleAgentMessage?.id === msg.id && ( - - )} - {latestVisibleMessage?.id === msg.id && ( -

- {formatRelativeTime(msg.createdAt)} -

- )} -
- ) : ( -
- {(() => { - const dataUris = Array.isArray(msg.extraMetadata?.attachmentDataUris) - ? (msg.extraMetadata.attachmentDataUris as string[]) - : parseMessageImages(msg.content ?? '').dataUris; - const hasImages = dataUris.length > 0; - // Document attachments carry no image data-URI (only - // images do); surface them as filename chips from the - // persisted attachmentKinds/attachmentNames metadata. - const kinds = Array.isArray(msg.extraMetadata?.attachmentKinds) - ? (msg.extraMetadata.attachmentKinds as string[]) - : []; - const names = Array.isArray(msg.extraMetadata?.attachmentNames) - ? (msg.extraMetadata.attachmentNames as string[]) - : []; - const fileNames = kinds - .map((k, i) => (k === 'file' ? names[i] : null)) - .filter((n): n is string => Boolean(n)); - const showTime = latestVisibleMessage?.id === msg.id; - return ( - <> - {hasImages && ( -
- {dataUris.map((uri, i) => ( - - ))} -
- )} - {fileNames.length > 0 && ( -
- {fileNames.map((name, i) => ( -
- - - - - {name} -
- ))} -
- )} - {(msg.content || showTime) && ( -
- {msg.content && ( - - )} - {showTime && ( -

- {formatRelativeTime(msg.createdAt)} -

- )} -
- )} - - ); - })()} -
- )} - - {(() => { - if (latestVisibleMessage?.id !== msg.id) return null; - const myReactions = - (msg.extraMetadata?.myReactions as string[] | undefined) ?? []; - const hasReactions = myReactions.length > 0; - // Show reaction row only for the most recent visible message. - if (!hasReactions && msg.sender !== 'agent') return null; - return ( -
- {myReactions.map(emoji => ( - - ))} - {msg.sender === 'agent' && - (reactionPickerMsgId === msg.id ? ( -
- {['👍', '❤️', '😂', '🔥', '👀', '🎯'].map(emoji => ( - + {(() => { + const raw = msg.extraMetadata?.citations; + if (!Array.isArray(raw)) return null; + const citations = raw.filter( + (item): item is MessageCitation => + typeof item === 'object' && + item !== null && + typeof (item as MessageCitation).id === 'string' && + typeof (item as MessageCitation).key === 'string' && + typeof (item as MessageCitation).snippet === 'string' && + typeof (item as MessageCitation).timestamp === 'string' + ); + if (citations.length === 0) return null; + return ; + })()} + {shouldRenderTimelineBeforeLatestAgentMessage && + latestVisibleAgentMessage?.id === msg.id && ( + + )} + {latestVisibleMessage?.id === msg.id && ( +

+ {formatRelativeTime(msg.createdAt)} +

+ )} +
+ ) : ( +
+ {(() => { + const dataUris = Array.isArray(msg.extraMetadata?.attachmentDataUris) + ? (msg.extraMetadata.attachmentDataUris as string[]) + : parseMessageImages(msg.content ?? '').dataUris; + const hasImages = dataUris.length > 0; + // Document attachments carry no image data-URI (only + // images do); surface them as filename chips from the + // persisted attachmentKinds/attachmentNames metadata. + const kinds = Array.isArray(msg.extraMetadata?.attachmentKinds) + ? (msg.extraMetadata.attachmentKinds as string[]) + : []; + const names = Array.isArray(msg.extraMetadata?.attachmentNames) + ? (msg.extraMetadata.attachmentNames as string[]) + : []; + const fileNames = kinds + .map((k, i) => (k === 'file' ? names[i] : null)) + .filter((n): n is string => Boolean(n)); + const showTime = latestVisibleMessage?.id === msg.id; + return ( + <> + {hasImages && ( +
+ {dataUris.map((uri, i) => ( + ))} -
- ) : ( + )} + {fileNames.length > 0 && ( +
+ {fileNames.map((name, i) => ( +
+ + + + + {name} +
+ ))} +
+ )} + {(msg.content || showTime) && ( +
+ {msg.content && ( + + )} + {showTime && ( +

+ {formatRelativeTime(msg.createdAt)} +

+ )} +
+ )} + + ); + })()} +
+ )} + + {(() => { + if (latestVisibleMessage?.id !== msg.id) return null; + const myReactions = + (msg.extraMetadata?.myReactions as string[] | undefined) ?? []; + const hasReactions = myReactions.length > 0; + // Show reaction row only for the most recent visible message. + if (!hasReactions && msg.sender !== 'agent') return null; + return ( +
+ {myReactions.map(emoji => ( + + ))} + {msg.sender === 'agent' && + (reactionPickerMsgId === msg.id ? ( +
+ {['👍', '❤️', '😂', '🔥', '👀', '🎯'].map(emoji => ( + + ))} - ))} -
- ); - })()} -
+
+ ) : ( + + ))} +
+ ); + })()}
- ); - })} - {isSending && - // Suppress the legacy 3-dot placeholder once streaming - // output (visible text or thinking) has started — the - // streaming preview bubble below takes over as the - // activity indicator. - !( - (selectedStreamingAssistant?.content.length ?? 0) > 0 || - (selectedStreamingAssistant?.thinking.length ?? 0) > 0 - ) && ( -
-
-
- - - -
+
+ ); + })} + {isSending && + // Suppress the legacy 3-dot placeholder once streaming + // output (visible text or thinking) has started — the + // streaming preview bubble below takes over as the + // activity indicator. + !( + (selectedStreamingAssistant?.content.length ?? 0) > 0 || + (selectedStreamingAssistant?.thinking.length ?? 0) > 0 + ) && ( +
+
+
+ + +
- )} - {/* Streaming assistant preview — compact trailing tail of the +
+ )} + {/* Streaming assistant preview — compact trailing tail of the in-flight response. Rendered as plain text (not Markdown) to avoid jitter from partially-parsed fences. The final bubble replaces this via addInferenceResponse on chat_done. */} - {selectedStreamingAssistant && - (selectedStreamingAssistant.content.length > 0 || - selectedStreamingAssistant.thinking.length > 0) && ( -
+ {selectedStreamingAssistant && + (selectedStreamingAssistant.content.length > 0 || + selectedStreamingAssistant.thinking.length > 0) && ( +
+
+ {selectedStreamingAssistant.thinking.length > 0 && ( +
+ + + {t('chat.thinking')} + +
+                          {selectedStreamingAssistant.thinking.slice(-STREAMING_PREVIEW_CHARS)}
+                        
+
+ )} + {selectedStreamingAssistant.content.length > 0 && ( +
+

+ {selectedStreamingAssistant.content.length > STREAMING_PREVIEW_CHARS && ( + + )} + {selectedStreamingAssistant.content.slice(-STREAMING_PREVIEW_CHARS)} + +

+
+ )} +
+
+ )} + {/* Parallel (forked) branch streams — concurrent turns on this + thread, each its own labeled bubble so they don't collide with + the primary stream above. */} + {selectedParallelStreams.map( + branch => + (branch.content.length > 0 || branch.thinking.length > 0) && ( +
- {selectedStreamingAssistant.thinking.length > 0 && ( -
- - - {t('chat.thinking')} - -
-                            {selectedStreamingAssistant.thinking.slice(-STREAMING_PREVIEW_CHARS)}
-                          
-
- )} - {selectedStreamingAssistant.content.length > 0 && ( -
+
+ + {t('chat.parallelBranchLabel')} +
+ {branch.content.length > 0 && ( +

- {selectedStreamingAssistant.content.length > - STREAMING_PREVIEW_CHARS && ( + {branch.content.length > STREAMING_PREVIEW_CHARS && ( )} - {selectedStreamingAssistant.content.slice(-STREAMING_PREVIEW_CHARS)} + {branch.content.slice(-STREAMING_PREVIEW_CHARS)}

)}
- )} - {/* Parallel (forked) branch streams — concurrent turns on this - thread, each its own labeled bubble so they don't collide with - the primary stream above. */} - {selectedParallelStreams.map( - branch => - (branch.content.length > 0 || branch.thinking.length > 0) && ( -
-
-
- - {t('chat.parallelBranchLabel')} -
- {branch.content.length > 0 && ( -
-

- {branch.content.length > STREAMING_PREVIEW_CHARS && ( - - )} - {branch.content.slice(-STREAMING_PREVIEW_CHARS)} - -

-
- )} -
-
- ) - )} - {/* Inference status indicator. + ) + )} + {/* Inference status indicator. For the tool_use / subagent phases this line just restates the active row already shown in the agentic-task-insights timeline, so suppress it once that timeline is on screen — keep it only for the `thinking` phase (which has no timeline row yet) or when there is no timeline to fall back on. */} - {selectedInferenceStatus && - (selectedInferenceStatus.phase === 'thinking' || - selectedThreadToolTimeline.length === 0) && ( -
- - - {selectedInferenceStatus.phase === 'thinking' && - (selectedInferenceStatus.iteration > 0 - ? t('chat.thinkingIteration').replace( - '{n}', - String(selectedInferenceStatus.iteration) - ) - : t('chat.thinkingDots'))} - {selectedInferenceStatus.phase === 'tool_use' && - `${ - formatTimelineEntry( - activeToolTimelineEntry ?? { - id: 'active-tool', - name: selectedInferenceStatus.activeTool ?? 'tool', - round: selectedInferenceStatus.iteration, - status: 'running', - } - ).title - }...`} - {selectedInferenceStatus.phase === 'subagent' && - `${ - formatTimelineEntry( - activeSubagentTimelineEntry ?? { - id: 'active-subagent', - name: `subagent:${selectedInferenceStatus.activeSubagent ?? ''}`, - round: selectedInferenceStatus.iteration, - status: 'running', - } - ).title - }...`} - -
- )} - {/* Tool call timeline */} - {selectedThreadToolTimeline.length > 0 && - !shouldRenderTimelineBeforeLatestAgentMessage && ( - setOpenSubagentTaskId(sub.taskId)} - /> - )} - {isSending && rustChat && ( -
- + {selectedInferenceStatus && + (selectedInferenceStatus.phase === 'thinking' || + selectedThreadToolTimeline.length === 0) && ( +
+ + + {selectedInferenceStatus.phase === 'thinking' && + (selectedInferenceStatus.iteration > 0 + ? t('chat.thinkingIteration').replace( + '{n}', + String(selectedInferenceStatus.iteration) + ) + : t('chat.thinkingDots'))} + {selectedInferenceStatus.phase === 'tool_use' && + `${ + formatTimelineEntry( + activeToolTimelineEntry ?? { + id: 'active-tool', + name: selectedInferenceStatus.activeTool ?? 'tool', + round: selectedInferenceStatus.iteration, + status: 'running', + } + ).title + }...`} + {selectedInferenceStatus.phase === 'subagent' && + `${ + formatTimelineEntry( + activeSubagentTimelineEntry ?? { + id: 'active-subagent', + name: `subagent:${selectedInferenceStatus.activeSubagent ?? ''}`, + round: selectedInferenceStatus.iteration, + status: 'running', + } + ).title + }...`} +
)} -
-
- ) : ( -
-

{t('chat.noMessages')}

-
- )} -
- -
- <> - {isNearLimit && - !isAtLimit && - isFreeTier && - shouldShowBanner('conversations-warning', 24 * 60 * 60 * 1000) && ( -
- { - void openUrl(BILLING_DASHBOARD_URL); - }} - dismissible - onDismiss={() => dismissBanner('conversations-warning')} - /> -
+ {/* Tool call timeline */} + {selectedThreadToolTimeline.length > 0 && + !shouldRenderTimelineBeforeLatestAgentMessage && ( + setOpenSubagentTaskId(sub.taskId)} + /> )} - {teamUsage && shouldShowBudgetCompletedMessage && ( -
-
- - - -

- {teamUsage.cycleBudgetUsd > 0 - ? `${t('chat.weeklyLimitHit')}${teamUsage.cycleEndsAt ? ` ${t('chat.resets')} ${formatResetTime(teamUsage.cycleEndsAt)}.` : ''} ${t('chat.topUpToContinue')}` - : t('chat.budgetComplete')} -

-
-
- - -
-
- )} - {openRouterStatus === 'error' && ( -
- {t('openrouterFree.error')} -
- )} - - {/* Cycle usage pill moved into ChatComposer toolbar */} - - - {sendAdvisory && ( -
-

- {sendAdvisory} -

- -
- )} - - {attachError && ( -
-

- {attachError.message} -

- -
- )} - - {sendError && ( -
-

- {sendError.message} -

-
- {(sendError.code === 'stt_not_ready' || - sendError.code === 'voice_transcription' || - sendError.code === 'tts_not_ready' || - sendError.code === 'voice_synthesis') && ( - - )} + {isSending && rustChat && ( +
+
+ )} +
+
+ ) : ( +
+

{t('chat.noMessages')}

+
+ )} +
+ +
+ <> + {isNearLimit && + !isAtLimit && + isFreeTier && + shouldShowBanner('conversations-warning', 24 * 60 * 60 * 1000) && ( +
+ { + void openUrl(BILLING_DASHBOARD_URL); + }} + dismissible + onDismiss={() => dismissBanner('conversations-warning')} + /> +
+ )} + {teamUsage && shouldShowBudgetCompletedMessage && ( +
+
+ + + +

+ {teamUsage.cycleBudgetUsd > 0 + ? `${t('chat.weeklyLimitHit')}${teamUsage.cycleEndsAt ? ` ${t('chat.resets')} ${formatResetTime(teamUsage.cycleEndsAt)}.` : ''} ${t('chat.topUpToContinue')}` + : t('chat.budgetComplete')} +

+
+
+ +
)} - - {(() => { - // Surface a parked ApprovalGate request for the shown thread just - // above the composer, so it stays visible regardless of scroll. - const approvalThreadId = selectedThreadId ?? firstActiveThreadId; - const pendingApproval = approvalThreadId - ? pendingApprovalByThread[approvalThreadId] - : undefined; - return pendingApproval && approvalThreadId ? ( -
- -
- ) : null; - })()} - - {(() => { - // Surface in-flight + failed artifact cards above the composer - // (#2779). Mirrors the approval-card placement so the user sees - // the spinner / error without scrolling. `ready` cards are - // delegated to the header ChatFilesChip panel (#3024) so the - // chat scroll area isn't permanently occupied — restored decks - // are listable from the chip on demand. - // - // NOTE: `onRetry` is intentionally omitted on `ArtifactCard` - // below — real retry (either `removeArtifact(thread, id)` to - // let the user re-prompt, or full re-dispatch of the producing - // tool call) is tracked in follow-up issue #3162. The - // failed-card UI still surfaces the truncated error reason; - // the button just stays hidden until #3162 lands. - const artifactThreadId = selectedThreadId ?? firstActiveThreadId; - const all = artifactThreadId ? (artifactsByThread[artifactThreadId] ?? []) : []; - const live = all.filter(a => a.status !== 'ready'); - if (live.length === 0) return null; - return ( -
- {live.map(artifact => ( - - ))} -
- ); - })()} - - {composer === 'mic-cloud' ? ( -
- handleSendMessage(text)} - onError={message => setSendError(chatSendError('voice_transcription', message))} - showDeviceSelector - onSwitchToText={() => setComposerOverride('text')} - /> -
- ) : inputMode === 'text' ? ( - setAttachments(prev => prev.filter(a => a.id !== id))} - attachError={attachError} - onSwitchToMicCloud={() => setComposerOverride('mic-cloud')} - handleInputKeyDown={handleInputKeyDown} - inlineCompletionSuffix={inlineCompletionSuffix} - isComposingTextRef={isComposingTextRef} - maxAttachments={ATTACHMENT_MAX_IMAGES + ATTACHMENT_MAX_FILES} - // Empty → no native `accept` filter (it greys valid files on - // macOS/CEF). Type enforcement happens in handleAttachFiles via - // validateAndReadFile, which honors modelSupportsVision. - allowedMimeTypes={[]} - attachmentsEnabled={CHAT_ATTACHMENTS_ENABLED} - /> - ) : ( -
- - -

- {voiceStatus ?? - (isPlayingReply && replyMode === 'voice' - ? t('chat.playingVoiceReply') - : canUseMicrophoneApi - ? t('chat.voiceHint') - : t('chat.micUnavailable'))} -

+ {openRouterStatus === 'error' && ( +
+ {t('openrouterFree.error')}
)} - -
+ + {/* Cycle usage pill moved into ChatComposer toolbar */} + + + {sendAdvisory && ( +
+

+ {sendAdvisory} +

+ +
+ )} + + {attachError && ( +
+

+ {attachError.message} +

+ +
+ )} + + {sendError && ( +
+

+ {sendError.message} +

+
+ {(sendError.code === 'stt_not_ready' || + sendError.code === 'voice_transcription' || + sendError.code === 'tts_not_ready' || + sendError.code === 'voice_synthesis') && ( + + )} + +
+
+ )} + + {(() => { + // Surface a parked ApprovalGate request for the shown thread just + // above the composer, so it stays visible regardless of scroll. + const approvalThreadId = selectedThreadId ?? firstActiveThreadId; + const pendingApproval = approvalThreadId + ? pendingApprovalByThread[approvalThreadId] + : undefined; + return pendingApproval && approvalThreadId ? ( +
+ +
+ ) : null; + })()} + + {(() => { + // Surface in-flight + failed artifact cards above the composer + // (#2779). Mirrors the approval-card placement so the user sees + // the spinner / error without scrolling. `ready` cards are + // delegated to the header ChatFilesChip panel (#3024) so the + // chat scroll area isn't permanently occupied — restored decks + // are listable from the chip on demand. + // + // NOTE: `onRetry` is intentionally omitted on `ArtifactCard` + // below — real retry (either `removeArtifact(thread, id)` to + // let the user re-prompt, or full re-dispatch of the producing + // tool call) is tracked in follow-up issue #3162. The + // failed-card UI still surfaces the truncated error reason; + // the button just stays hidden until #3162 lands. + const artifactThreadId = selectedThreadId ?? firstActiveThreadId; + const all = artifactThreadId ? (artifactsByThread[artifactThreadId] ?? []) : []; + const live = all.filter(a => a.status !== 'ready'); + if (live.length === 0) return null; + return ( +
+ {live.map(artifact => ( + + ))} +
+ ); + })()} + + {composer === 'mic-cloud' ? ( +
+ handleSendMessage(text)} + onError={message => setSendError(chatSendError('voice_transcription', message))} + showDeviceSelector + onSwitchToText={() => setComposerOverride('text')} + /> +
+ ) : inputMode === 'text' ? ( + setAttachments(prev => prev.filter(a => a.id !== id))} + attachError={attachError} + onSwitchToMicCloud={() => setComposerOverride('mic-cloud')} + handleInputKeyDown={handleInputKeyDown} + inlineCompletionSuffix={inlineCompletionSuffix} + isComposingTextRef={isComposingTextRef} + maxAttachments={ATTACHMENT_MAX_IMAGES + ATTACHMENT_MAX_FILES} + // Empty → no native `accept` filter (it greys valid files on + // macOS/CEF). Type enforcement happens in handleAttachFiles via + // validateAndReadFile, which honors modelSupportsVision. + allowedMimeTypes={[]} + attachmentsEnabled={CHAT_ATTACHMENTS_ENABLED} + /> + ) : ( +
+ + +

+ {voiceStatus ?? + (isPlayingReply && replyMode === 'voice' + ? t('chat.playingVoiceReply') + : canUseMicrophoneApi + ? t('chat.voiceHint') + : t('chat.micUnavailable'))} +

+
+ )} +
+
+ ); + + return ( +
+ {isSidebar ? ( + mainPanel + ) : ( + // Max-width is applied to the whole two-pane layout (sidebar + chat + // together) and centered, rather than capping the chat pane alone. The + // cap widens when the threads pane is shown so the chat keeps a + // comfortable reading width in both states. + + {mainPanel} + + )} setDeleteModal(prev => ({ ...prev, isOpen: false }))} diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index 4d72e75a5..a1d7fcb4e 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -61,7 +61,17 @@ const makeIsVisibleTab = (INTELLIGENCE_TABS as string[]).includes(tab ?? '') && (developerModeEnabled || !(DEV_ONLY_TABS as string[]).includes(tab ?? '')); -export default function Intelligence() { +interface IntelligenceProps { + /** + * Query-param key backing the active tab. Defaults to `tab` for the standalone + * route. When embedded inside another `?tab=`-driven page (e.g. Brain at + * `/brain?tab=intelligence`), pass a distinct key so the child's internal tab + * switches don't clobber the host's `tab` param and unmount this panel. + */ + tabParamKey?: string; +} + +export default function Intelligence({ tabParamKey = 'tab' }: IntelligenceProps = {}) { const { t } = useT(); const { navigateBack, breadcrumbs } = useSettingsNavigation(); const developerMode = useDeveloperMode(); @@ -72,24 +82,24 @@ export default function Intelligence() { // for an `embedded` prop since this page has no standalone usage. log('rendering with settings shell'); - // Tab is URL-backed (`/intelligence?tab=…`) so navigating away — e.g. to + // Tab is URL-backed (`?=…`) so navigating away — e.g. to // Settings → Task Sources from the Agent Tasks tab — and coming back via // browser-back restores the same tab instead of resetting to Memory. // `replace` so switching tabs doesn't stack history entries. const [searchParams, setSearchParams] = useSearchParams(); - const tabParam = searchParams.get('tab'); + const tabParam = searchParams.get(tabParamKey); const activeTab: IntelligenceTab = isVisibleTab(tabParam) ? tabParam : 'tasks'; const setActiveTab = useCallback( (tab: IntelligenceTab) => { setSearchParams( prev => { - prev.set('tab', tab); + prev.set(tabParamKey, tab); return prev; }, { replace: true } ); }, - [setSearchParams] + [setSearchParams, tabParamKey] ); // The legacy header pills (system-status + Ingesting/Queued chips) were diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 0c59075ca..17c055fb3 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -1,45 +1,37 @@ import type { ReactNode } from 'react'; -import { Navigate, Route, Routes, useNavigate } from 'react-router-dom'; +import { Navigate, Route, Routes } from 'react-router-dom'; -import CostDashboardPanel from '../components/dashboard/CostDashboardPanel'; import WorkflowsTab from '../components/intelligence/WorkflowsTab'; -import LogoutAndClearActions from '../components/settings/LogoutAndClearActions'; +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 AgentChatPanel from '../components/settings/panels/AgentChatPanel'; import AgentEditorPage from '../components/settings/panels/AgentEditorPage'; import AgentsPanel from '../components/settings/panels/AgentsPanel'; -import AIPanel from '../components/settings/panels/AIPanel'; -import AnalysisViewsPanel from '../components/settings/panels/AnalysisViewsPanel'; 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 AutonomyPanel from '../components/settings/panels/AutonomyPanel'; import BillingPanel from '../components/settings/panels/BillingPanel'; import CompanionPanel from '../components/settings/panels/CompanionPanel'; -import ComposioPanel from '../components/settings/panels/ComposioPanel'; import ComposioTriagePanel from '../components/settings/panels/ComposioTriagePanel'; import CronJobsPanel from '../components/settings/panels/CronJobsPanel'; import DeveloperOptionsPanel from '../components/settings/panels/DeveloperOptionsPanel'; -import DevicesComingSoonPanel from '../components/settings/panels/DevicesComingSoonPanel'; +import DevicesPanel from '../components/settings/panels/DevicesPanel'; import DevWorkflowPanel from '../components/settings/panels/DevWorkflowPanel'; -import EmbeddingsPanel from '../components/settings/panels/EmbeddingsPanel'; import EventLogPanel from '../components/settings/panels/EventLogPanel'; -import HeartbeatPanel from '../components/settings/panels/HeartbeatPanel'; -import LedgerUsagePanel from '../components/settings/panels/LedgerUsagePanel'; +import IntegrationsPanel from '../components/settings/panels/IntegrationsPanel'; import LocalModelDebugPanel from '../components/settings/panels/LocalModelDebugPanel'; -import MascotPanel from '../components/settings/panels/MascotPanel'; import McpServerPanel from '../components/settings/panels/McpServerPanel'; -import MemoryDataPanel from '../components/settings/panels/MemoryDataPanel'; -import MemoryDebugPanel from '../components/settings/panels/MemoryDebugPanel'; 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 PersonaPanel from '../components/settings/panels/PersonaPanel'; +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'; @@ -47,9 +39,7 @@ import RecoveryPhrasePanel from '../components/settings/panels/RecoveryPhrasePan import SandboxSettingsPanel from '../components/settings/panels/SandboxSettingsPanel'; import ScreenAwarenessDebugPanel from '../components/settings/panels/ScreenAwarenessDebugPanel'; import ScreenIntelligencePanel from '../components/settings/panels/ScreenIntelligencePanel'; -import SearchPanel from '../components/settings/panels/SearchPanel'; import SecurityPanel from '../components/settings/panels/SecurityPanel'; -import TaskSourcesPanel from '../components/settings/panels/TaskSourcesPanel'; import TasksPanel from '../components/settings/panels/TasksPanel'; import TeamInvitesPanel from '../components/settings/panels/TeamInvitesPanel'; import TeamManagementPanel from '../components/settings/panels/TeamManagementPanel'; @@ -57,604 +47,183 @@ 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 VoicePanel from '../components/settings/panels/VoicePanel'; import WalletBalancesPanel from '../components/settings/panels/WalletBalancesPanel'; import WebhooksDebugPanel from '../components/settings/panels/WebhooksDebugPanel'; import WorkflowRunnerPanel from '../components/settings/panels/WorkflowRunnerPanel'; -import SettingsHome from '../components/settings/SettingsHome'; -import SettingsSectionPage from '../components/settings/SettingsSectionPage'; -import { useT } from '../lib/i18n/I18nContext'; -import { APP_VERSION } from '../utils/config'; -import Intelligence from './Intelligence'; -import Webhooks from './Webhooks'; -// Icon elements extracted as constants to avoid repeating JSX in each array factory below. -const RecoveryPhraseIcon = ( - - - -); -const TeamIcon = ( - - - -); -const PrivacyIcon = ( - - - -); -const SecurityIcon = ( - - - -); -const MigrationIcon = ( - - - -); -const ScreenIcon = ( - - - -); -const NotificationsIcon = ( - - - -); -const ToolsIcon = ( - - - - -); -const NotificationSettingsIcon = ( - - - -); -const LlmIcon = ( - - - -); -const CompanionIcon = ( - - - -); -const VoiceIcon = ( - - - -); - -const AgentAccessIcon = ( - - - -); - -const WalletIcon = ( - - - -); - -const WrappedSettingsPage = ({ - children, - // Default widened ~30% (max-w-lg 512px → max-w-2xl 672px) for a roomier - // settings list per design feedback. - maxWidthClass = 'max-w-2xl', -}: { - children: ReactNode; - maxWidthClass?: string; -}) => { - return ( -
-
- {children} -
-
- ); +const WrappedSettingsPage = ({ children }: { children: ReactNode }) => { + // The surrounding two-pane card (bg / border / rounding) is now provided by + // SettingsLayout's content pane, so panels sit directly on it — matching the + // conversations page. The max-width is applied once to the whole settings + // panel (SettingsLayout), so individual panels just fill the content pane. + return
{children}
; }; +/** + * 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. + */ const Settings = () => { - const { t } = useT(); - const navigate = useNavigate(); - - const wrapSettingsPage = (element: ReactNode, opts?: { maxWidthClass?: string }) => ( - - {element} -
- {t('settings.betaBuild').replace('{version}', APP_VERSION)} -
-
+ const wrapSettingsPage = (element: ReactNode) => ( + {element} ); - const accountSettingsItems = [ - { - id: 'team', - title: t('pages.settings.account.team'), - description: t('pages.settings.account.teamDesc'), - route: 'team', - icon: TeamIcon, - }, - { - id: 'privacy', - title: t('pages.settings.account.privacy'), - description: t('pages.settings.account.privacyDesc'), - route: 'privacy', - icon: PrivacyIcon, - }, - { - id: 'security', - title: t('pages.settings.account.security'), - description: t('pages.settings.account.securityDesc'), - route: 'security', - icon: SecurityIcon, - }, - { - id: 'migration', - title: t('pages.settings.account.migration'), - description: t('pages.settings.account.migrationDesc'), - route: 'migration', - icon: MigrationIcon, - }, - ]; - - // Notifications hub (lives under Advanced) — gathers the Alerts inbox and the - // notification preferences/routing panel under one section page. - const notificationsHubItems = [ - { - id: 'automations', - title: t('activity.tabs.automations'), - description: t('activity.tabs.automationsDescription'), - route: 'automations', - icon: NotificationsIcon, - }, - { - id: 'alerts', - title: t('nav.alerts'), - description: t('settings.alertsDesc'), - onClick: () => navigate('/notifications'), - icon: NotificationsIcon, - }, - { - id: 'notification-settings', - title: t('settings.notificationsHub.settingsItem'), - description: t('settings.notificationsHub.settingsItemDesc'), - route: 'notifications', - icon: NotificationSettingsIcon, - }, - ]; - - const cryptoSettingsItems = [ - { - id: 'recovery-phrase', - title: t('pages.settings.account.recoveryPhrase'), - description: t('pages.settings.account.recoveryPhraseDesc'), - route: 'recovery-phrase', - icon: RecoveryPhraseIcon, - }, - { - id: 'wallet-balances', - title: t('pages.settings.account.walletBalances'), - description: t('pages.settings.account.walletBalancesDesc'), - route: 'wallet-balances', - icon: WalletIcon, - }, - ]; - - const featuresSettingsItems = [ - { - id: 'screen-intelligence', - title: t('pages.settings.features.screenAwareness'), - description: t('pages.settings.features.screenAwarenessDesc'), - route: 'screen-intelligence', - icon: ScreenIcon, - }, - // Autocomplete + Voice Dictation hidden per #717 (routes retained for re-enable). - // notifications moved to notifications-hub section (no longer duplicated here). - { - id: 'tools', - title: t('pages.settings.features.tools'), - description: t('pages.settings.features.toolsDesc'), - route: 'tools', - icon: ToolsIcon, - }, - { - id: 'companion', - title: t('pages.settings.features.desktopCompanion'), - description: t('pages.settings.features.desktopCompanionDesc'), - route: 'companion', - icon: CompanionIcon, - }, - ]; - - // agent-chat and local-model-debug are debug tools — they live only in - // Developer & Diagnostics, not in the AI section page. - const aiSettingsItems = [ - { - id: 'llm', - title: t('pages.settings.ai.llm'), - description: t('pages.settings.ai.llmDesc'), - route: 'llm', - icon: LlmIcon, - }, - { - id: 'embeddings', - title: t('pages.settings.ai.embeddings'), - description: t('pages.settings.ai.embeddingsDesc'), - route: 'embeddings', - icon: LlmIcon, - }, - { - id: 'voice', - title: t('pages.settings.ai.voice'), - description: t('pages.settings.ai.voiceDesc'), - route: 'voice', - icon: VoiceIcon, - }, - { - id: 'heartbeat', - title: t('settings.heartbeat.title'), - description: t('settings.heartbeat.desc'), - route: 'heartbeat', - icon: LlmIcon, - }, - { - id: 'ledger-usage', - title: t('settings.ledgerUsage.title'), - description: t('settings.ledgerUsage.desc'), - route: 'ledger-usage', - icon: LlmIcon, - }, - { - id: 'cost-dashboard', - title: t('settings.costDashboard.title'), - description: t('settings.costDashboard.desc'), - route: 'cost-dashboard', - icon: LlmIcon, - }, - ]; - - // persona has its own canonical home entry (Assistant group) — not duplicated here. - const agentsSettingsItems = [ - { - id: 'agents', - title: t('settings.agents.title'), - description: t('settings.agents.subtitle'), - route: 'agents', - icon: ToolsIcon, - }, - { - id: 'autonomy', - title: t('settings.developerMenu.autonomy.title'), - description: t('settings.developerMenu.autonomy.desc'), - route: 'autonomy', - icon: LlmIcon, - }, - { - id: 'agent-access', - title: t('settings.agentAccess.title'), - description: t('settings.agentAccess.menuDesc'), - route: 'agent-access', - icon: AgentAccessIcon, - }, - { - id: 'activity-level', - title: t('activityLevel.title'), - description: t('activityLevel.description'), - route: 'activity-level', - icon: LlmIcon, - }, - { - id: 'sandbox-settings', - title: t('settings.sandbox.title'), - description: t('settings.sandbox.menuDesc'), - route: 'sandbox-settings', - icon: AgentAccessIcon, - }, - ]; - - const composioSettingsItems = [ - { - id: 'task-sources', - title: t('settings.taskSources.title'), - description: t('settings.taskSources.subtitle'), - route: 'task-sources', - icon: ToolsIcon, - }, - { - id: 'composio-routing', - title: t('settings.developerMenu.composioRouting.title'), - description: t('settings.developerMenu.composioRouting.desc'), - route: 'composio-routing', - icon: ToolsIcon, - }, - { - id: 'webhooks-triggers', - title: t('settings.developerMenu.composeioTriggers.title'), - description: t('settings.developerMenu.composeioTriggers.desc'), - route: 'webhooks-triggers', - icon: ToolsIcon, - }, - ]; - 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. +
- )} /> - } - /> - )} - /> - - )} - /> - - )} - /> - - )} - /> - - )} - /> - - )} - /> - - )} - /> - {/* Account & Billing leaf panels */} - )} /> - )} /> - )} /> - )} - /> - )} - /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - {/* Features leaf panels */} - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - )} /> - {/* Developer Options */} - )} /> - )} - /> - )} /> - )} /> - {/* Legacy direct path for the routing tab — kept so existing links - (Developer Options entries, walkthroughs) keep working. The - tabbed panel reads the URL hash to land on the right tab. */} - } - /> - , { maxWidthClass: 'max-w-4xl' })} /> - )} /> - , { maxWidthClass: 'max-w-4xl' })} - /> - , { maxWidthClass: 'max-w-4xl' })} - /> - , { maxWidthClass: 'max-w-4xl' })} - /> - )} /> - )} /> - )} /> - )} /> - )} /> - , { maxWidthClass: 'max-w-4xl' })} - /> - )} /> - )} /> - )} - /> - )} /> - )} /> - )} /> - )} /> - )} /> - , { maxWidthClass: 'max-w-4xl' })} - /> - , { maxWidthClass: 'max-w-4xl' })} - /> - )} /> - )} /> - , { maxWidthClass: 'max-w-4xl' })} - /> - , { maxWidthClass: 'max-w-4xl' })} - /> - , { maxWidthClass: 'max-w-4xl' })} - /> - )} /> - )} /> - {/* Mobile devices */} - )} /> - {/* About / updates */} - )} /> - {/* Fallback */} - } /> + }> + } /> + + {/* ── 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 */} + } /> + } /> + } + /> + } /> + } + /> + } /> + {/* 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/pages/Skills.tsx b/app/src/pages/Skills.tsx index 83bb84b7d..0a533fb4d 100644 --- a/app/src/pages/Skills.tsx +++ b/app/src/pages/Skills.tsx @@ -11,7 +11,13 @@ import { } from '../components/composio/toolkitMeta'; import EmptyStateCard from '../components/EmptyStateCard'; import { ToastContainer } from '../components/intelligence/Toast'; -import PillTabBar from '../components/PillTabBar'; +import TwoPanelLayout from '../components/layout/TwoPanelLayout'; +import TwoPaneNav from '../components/layout/TwoPaneNav'; +import { SettingsLayoutProvider } from '../components/settings/layout/SettingsLayoutContext'; +import AIPanel from '../components/settings/panels/AIPanel'; +import EmbeddingsPanel from '../components/settings/panels/EmbeddingsPanel'; +import SearchPanel from '../components/settings/panels/SearchPanel'; +import VoicePanel from '../components/settings/panels/VoicePanel'; import AutocompleteSetupModal from '../components/skills/AutocompleteSetupModal'; import MeetingBotsCard from '../components/skills/MeetingBotsCard'; import ScreenIntelligenceSetupModal from '../components/skills/ScreenIntelligenceSetupModal'; @@ -45,6 +51,13 @@ import { IS_DEV } from '../utils/config'; import { isLocalSessionToken } from '../utils/localSession'; import { openhumanComposioGetMode } from '../utils/tauriCommands'; +/** Small inline icon helper for the Connections sidebar nav. */ +const navIcon = (d: string) => ( + + + +); + function channelStatusLabel(status: ChannelConnectionStatus, t: (key: string) => string): string { switch (status) { case 'connected': @@ -356,7 +369,24 @@ interface SkillItem { * Back-compat: the old ?tab= values (composio, channels, mcp, meetings) are * normalised to the new values so existing deep links continue to work. */ -type ConnectionsTab = 'composio' | 'channels' | 'mcp' | 'skills' | 'meetings'; +type ConnectionsTab = + | 'composio' + | 'channels' + | 'mcp' + | 'skills' + | 'meetings' + | 'llm' + | 'voice' + | 'embeddings' + | 'search'; + +/** Tabs that render a relocated settings panel (Intelligence group). */ +const INTELLIGENCE_TABS: ReadonlySet = new Set([ + 'llm', + 'voice', + 'embeddings', + 'search', +]); export default function Skills() { const { t } = useT(); @@ -376,7 +406,11 @@ export default function Skills() { raw === 'channels' || raw === 'mcp' || raw === 'skills' || - raw === 'meetings' + raw === 'meetings' || + raw === 'llm' || + raw === 'voice' || + raw === 'embeddings' || + raw === 'search' ) return raw; // Legacy back-compat aliases @@ -773,10 +807,102 @@ export default function Skills() { ); return ( -
-
-
-
+
+ handleTabChange(value as ConnectionsTab)} + header={ +

+ {t('nav.connections')} +

+ } + groups={[ + { + label: t('connections.groups.integrations'), + items: [ + { + value: 'composio', + label: t('connections.tabs.composio'), + icon: navIcon('M13 10V3L4 14h7v7l9-11h-7z'), + }, + { + value: 'channels', + label: t('connections.tabs.channels'), + icon: navIcon( + 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z' + ), + }, + { + value: 'mcp', + label: t('connections.tabs.mcp'), + icon: navIcon( + 'M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z' + ), + }, + { + value: 'skills', + label: t('connections.tabs.skills'), + icon: navIcon( + 'M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664zM21 12a9 9 0 11-18 0 9 9 0 0118 0z' + ), + }, + { + value: 'meetings', + label: t('connections.tabs.meetings'), + icon: navIcon( + 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z' + ), + }, + ], + }, + { + label: t('connections.groups.intelligence'), + items: [ + { + value: 'llm', + label: t('pages.settings.ai.llm'), + icon: navIcon( + 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z' + ), + }, + { + value: 'voice', + label: t('pages.settings.ai.voice'), + icon: navIcon( + 'M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z' + ), + }, + { + value: 'embeddings', + label: t('pages.settings.ai.embeddings'), + icon: navIcon( + 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4' + ), + }, + { + value: 'search', + label: t('settings.search.title'), + icon: navIcon('M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'), + }, + ], + }, + ]} + /> + }> +
+
{/*

@@ -822,17 +948,6 @@ export default function Skills() {

)} - - selected={activeTab} - onChange={handleTabChange} - items={[ - { value: 'composio', label: t('connections.tabs.composio') }, - { value: 'channels', label: t('connections.tabs.channels') }, - { value: 'mcp', label: t('connections.tabs.mcp') }, - { value: 'skills', label: t('connections.tabs.skills') }, - { value: 'meetings', label: t('connections.tabs.meetings') }, - ]} - /> { <> {activeTab === 'channels' && channelsGroup && ( @@ -900,23 +1015,18 @@ export default function Skills() { {activeTab === 'composio' && (
-
-

- {t('skills.integrations')} -

- - {t('skills.composio.poweredBy')} - -
+

+ {t('skills.integrations')} +

{t('skills.integrationsSubtitle')}

{showLocalComposioApiKeyBanner && ( navigate('/settings/composio-routing')} + onOpenSettings={() => navigate('/settings/integrations#composio')} /> )} {!showLocalComposioApiKeyBanner && ( @@ -997,11 +1107,25 @@ export default function Skills() {
)} + + {/* Intelligence panels relocated from Settings. Wrapped in the + settings two-pane context so their headers hide the back + button (the sidebar provides navigation here). */} + {INTELLIGENCE_TABS.has(activeTab) && ( +
+ + {activeTab === 'llm' && } + {activeTab === 'voice' && } + {activeTab === 'embeddings' && } + {activeTab === 'search' && } + +
+ )} }
-
+
{channelModalDef && ( setChannelModalDef(null)} /> diff --git a/app/src/pages/Webhooks.tsx b/app/src/pages/Webhooks.tsx index 7afaeea12..de1157e67 100644 --- a/app/src/pages/Webhooks.tsx +++ b/app/src/pages/Webhooks.tsx @@ -11,11 +11,18 @@ import { useT } from '../lib/i18n/I18nContext'; const log = debug('settings:webhooks'); -export default function Webhooks() { +interface WebhooksProps { + /** When true the page is hosted inside another settings page (the + * Integrations tabs) — skip the standalone SettingsHeader chrome and render + * the status badge + refresh action inline at the top of the body. */ + embedded?: boolean; +} + +export default function Webhooks({ embedded = false }: WebhooksProps) { const { t } = useT(); - // [settings] Webhooks is rendered exclusively at /settings/webhooks-triggers. - // Always apply the settings shell — no standalone usage exists. - log('rendering with settings shell'); + // [settings] Webhooks renders at /settings/integrations#webhooks (embedded) + // — the legacy standalone /settings/webhooks-triggers slug redirects there. + log('rendering with settings shell embedded=%s', embedded); const { navigateBack, breadcrumbs } = useSettingsNavigation(); const { archiveDir, currentDayFile, entries, loading, error, coreConnected, refresh } = useComposeioTriggerHistory(100); @@ -23,12 +30,14 @@ export default function Webhooks() { if (loading && entries.length === 0) { return (
- + {!embedded && ( + + )}
@@ -41,37 +50,42 @@ export default function Webhooks() { ); } + const statusActions = ( +
+ {/* Bespoke connection status badge — keep intentional visual */} + + + {coreConnected ? t('skills.connected') : t('skills.disconnect')} + + +
+ ); + return (
- - {/* Bespoke connection status badge — keep intentional visual */} - - - {coreConnected ? t('skills.connected') : t('skills.disconnect')} - - -
- } - /> + {!embedded && ( + + )}
+ {embedded &&
{statusActions}
} {error &&
{error}
} {/* Archive paths info — bespoke data-display layout, kept as-is */} diff --git a/app/src/pages/__tests__/Brain.test.tsx b/app/src/pages/__tests__/Brain.test.tsx index db391bae0..2fb6cc035 100644 --- a/app/src/pages/__tests__/Brain.test.tsx +++ b/app/src/pages/__tests__/Brain.test.tsx @@ -1,6 +1,7 @@ -import { act, render, screen, waitFor } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderWithProviders } from '../../test/test-utils'; import Brain from '../Brain'; const graphExportMock = vi.hoisted(() => vi.fn()); @@ -69,7 +70,7 @@ describe('Brain page', () => { it('renders the graph once data is fetched', async () => { graphExportMock.mockResolvedValue(makeGraph(3)); await act(async () => { - render(); + renderWithProviders(); }); await waitFor(() => { expect(screen.getByTestId('memory-graph')).toHaveTextContent('nodes:3'); @@ -79,7 +80,7 @@ describe('Brain page', () => { it('renders empty-state graph when there are no nodes', async () => { graphExportMock.mockResolvedValue(makeGraph(0)); await act(async () => { - render(); + renderWithProviders(); }); await waitFor(() => { expect(screen.getByTestId('memory-graph')).toHaveTextContent('nodes:0'); @@ -89,7 +90,7 @@ describe('Brain page', () => { it('surfaces an error alert when the fetch fails', async () => { graphExportMock.mockRejectedValue(new Error('boom')); await act(async () => { - render(); + renderWithProviders(); }); await waitFor(() => { expect(screen.getByRole('alert')).toBeInTheDocument(); diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index d248d2f28..c046e2680 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -22,6 +22,7 @@ import chatRuntimeReducer, { setTaskBoardForThread, setToolTimelineForThread, } from '../../store/chatRuntimeSlice'; +import layoutReducer from '../../store/layoutSlice'; import socketReducer from '../../store/socketSlice'; import themeReducer from '../../store/themeSlice'; import threadReducer, { setSelectedThread } from '../../store/threadSlice'; @@ -170,6 +171,7 @@ function buildStore(preload: Record = {}) { return configureStore({ reducer: combineReducers({ thread: threadReducer, + layout: layoutReducer, socket: socketReducer, chatRuntime: chatRuntimeReducer, agentProfiles: agentProfileReducer, @@ -220,7 +222,6 @@ async function openSidebar() { const emptyThreadState = { threads: [], selectedThreadId: null, - threadSidebarVisible: false, activeThreadIds: {}, welcomeThreadId: null, messagesByThreadId: {}, @@ -315,7 +316,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { }); }); - // Covers line 906: const effectiveShowSidebar = threadSidebarVisible; + // Covers the page-mode sidebar (TwoPanelLayout, id `chat`) once opened. // Covers line 941:
(always rendered in page mode) it('renders the sidebar pill tabs in page mode', async () => { await act(async () => { @@ -331,7 +332,9 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { let renderedStore: ReturnType | undefined; await act(async () => { renderedStore = await renderConversations({ - thread: { ...emptyThreadState, threadSidebarVisible: true }, + thread: emptyThreadState, + // Sidebar visibility now lives in the reusable `layout` slice (id `chat`). + layout: { panels: { chat: { sidebarVisible: true, sidebarWidth: 256 } } }, }); }); @@ -344,7 +347,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { await waitFor(() => { expect(screen.queryByText('General')).not.toBeInTheDocument(); }); - expect(renderedStore?.getState().thread.threadSidebarVisible).toBe(false); + expect(renderedStore?.getState().layout.panels.chat.sidebarVisible).toBe(false); }); // Covers line 941 empty branch diff --git a/app/src/pages/__tests__/Skills.channels-grid.test.tsx b/app/src/pages/__tests__/Skills.channels-grid.test.tsx index a4be6956a..73da06947 100644 --- a/app/src/pages/__tests__/Skills.channels-grid.test.tsx +++ b/app/src/pages/__tests__/Skills.channels-grid.test.tsx @@ -70,7 +70,7 @@ describe('Skills page — Channels grid', () => { renderWithProviders(, { initialEntries: ['/connections'] }); // Switch to the Channels tab to make the Channels card visible. - fireEvent.click(screen.getByRole('tab', { name: 'Channels' })); + fireEvent.click(screen.getByTestId('two-pane-nav-channels')); const channelsHeading = screen.getByRole('heading', { name: 'Messaging' }); expect(channelsHeading).toBeInTheDocument(); @@ -136,7 +136,7 @@ describe('Skills page — Channels grid', () => { renderWithProviders(, { initialEntries: ['/connections'], preloadedState }); // Switch to the Channels tab so the Channels card is visible. - fireEvent.click(screen.getByRole('tab', { name: 'Channels' })); + fireEvent.click(screen.getByTestId('two-pane-nav-channels')); const channelsCard = screen .getByRole('heading', { name: 'Messaging' }) .closest('.rounded-2xl'); @@ -149,7 +149,7 @@ describe('Skills page — Channels grid', () => { it('does not surface a Channels chip in the category filter inside the Integrations card', () => { renderWithProviders(, { initialEntries: ['/connections'] }); - fireEvent.click(screen.getByRole('tab', { name: 'Composio' })); + fireEvent.click(screen.getByTestId('two-pane-nav-composio')); // The Composio tab owns the Integrations category filter. const integrationsHeading = screen.getByRole('heading', { name: 'Composio Integrations' }); diff --git a/app/src/pages/__tests__/Skills.composio-catalog.test.tsx b/app/src/pages/__tests__/Skills.composio-catalog.test.tsx index 6acfdc343..2485ded9a 100644 --- a/app/src/pages/__tests__/Skills.composio-catalog.test.tsx +++ b/app/src/pages/__tests__/Skills.composio-catalog.test.tsx @@ -79,7 +79,7 @@ describe('Skills page — Composio catalog fallback', () => { }); function openAppsTab() { - fireEvent.click(screen.getByRole('tab', { name: 'Composio' })); + fireEvent.click(screen.getByTestId('two-pane-nav-composio')); } it('shows known composio integrations in the integrations icon grid when the live toolkit list is empty', () => { diff --git a/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx b/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx index ff3e743a7..39cf68de4 100644 --- a/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx +++ b/app/src/pages/__tests__/Skills.mcp-coming-soon.test.tsx @@ -53,7 +53,7 @@ describe('Skills page — MCP Servers tab (MCP + Meeting bots)', () => { it('renders the MCP servers table in the MCP Servers tab', async () => { renderWithProviders(, { initialEntries: ['/connections'] }); - fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' })); + fireEvent.click(screen.getByTestId('two-pane-nav-mcp')); // The Tools tab shows filter chips (All / Installed / Registry) and a search input await waitFor(() => { @@ -66,7 +66,7 @@ describe('Skills page — MCP Servers tab (MCP + Meeting bots)', () => { it('shows the table header columns on the MCP Servers tab', async () => { renderWithProviders(, { initialEntries: ['/connections'] }); - fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' })); + fireEvent.click(screen.getByTestId('two-pane-nav-mcp')); // Wait for initial load to complete await waitFor(() => { @@ -81,7 +81,7 @@ describe('Skills page — MCP Servers tab (MCP + Meeting bots)', () => { it('shows empty-installed state when Installed chip is clicked', async () => { renderWithProviders(, { initialEntries: ['/connections'] }); - fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' })); + fireEvent.click(screen.getByTestId('two-pane-nav-mcp')); await waitFor(() => { expect(screen.getByRole('button', { name: /Installed/i })).toBeInTheDocument(); @@ -96,10 +96,7 @@ describe('Skills page — MCP Servers tab (MCP + Meeting bots)', () => { it('supports direct links via legacy ?tab=mcp (normalised to mcp-servers)', async () => { renderWithProviders(, { initialEntries: ['/connections?tab=mcp'] }); - expect(screen.getByRole('tab', { name: 'MCP Servers' })).toHaveAttribute( - 'aria-selected', - 'true' - ); + expect(screen.getByTestId('two-pane-nav-mcp')).toHaveAttribute('aria-current', 'page'); await waitFor(() => { expect(screen.getByRole('button', { name: 'All' })).toBeInTheDocument(); }); diff --git a/app/src/pages/__tests__/Skills.meetings-tab.test.tsx b/app/src/pages/__tests__/Skills.meetings-tab.test.tsx index 228bf8b75..46c6e0b4b 100644 --- a/app/src/pages/__tests__/Skills.meetings-tab.test.tsx +++ b/app/src/pages/__tests__/Skills.meetings-tab.test.tsx @@ -45,11 +45,11 @@ describe('Skills page — Meetings tab (meeting bots)', () => { expect(screen.queryByTestId('meeting-bots-card')).not.toBeInTheDocument(); // MCP Servers no longer hosts the meeting bot CTA. - fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' })); + fireEvent.click(screen.getByTestId('two-pane-nav-mcp')); expect(screen.queryByTestId('meeting-bots-card')).not.toBeInTheDocument(); // Meetings does. - fireEvent.click(screen.getByRole('tab', { name: 'Meetings' })); + fireEvent.click(screen.getByTestId('two-pane-nav-meetings')); expect(screen.getByTestId('meeting-bots-card')).toBeInTheDocument(); }); @@ -57,14 +57,14 @@ describe('Skills page — Meetings tab (meeting bots)', () => { // The old ?tab=meetings alias now maps to the new "Meetings" tab. renderWithProviders(, { initialEntries: ['/connections?tab=meetings'] }); - expect(screen.getByRole('tab', { name: 'Meetings' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByTestId('two-pane-nav-meetings')).toHaveAttribute('aria-current', 'page'); expect(screen.getByTestId('meeting-bots-card')).toBeInTheDocument(); }); it('supports direct links via ?tab=talents', () => { renderWithProviders(, { initialEntries: ['/connections?tab=talents'] }); - expect(screen.getByRole('tab', { name: 'Meetings' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByTestId('two-pane-nav-meetings')).toHaveAttribute('aria-current', 'page'); expect(screen.getByTestId('meeting-bots-card')).toBeInTheDocument(); }); }); diff --git a/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx b/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx index 317dfe937..ca4dd6184 100644 --- a/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx +++ b/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx @@ -41,7 +41,7 @@ vi.mock('../../lib/composio/hooks', () => ({ describe('Skills page — Gmail composio integration', () => { it('renders Gmail as a connected composio integration and opens its management modal', async () => { renderWithProviders(, { initialEntries: ['/connections'] }); - fireEvent.click(screen.getByRole('tab', { name: 'Composio' })); + fireEvent.click(screen.getByTestId('two-pane-nav-composio')); const integrationsSection = screen .getByRole('heading', { name: 'Composio Integrations' }) diff --git a/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx b/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx index c5a2e3095..0fd07e9fa 100644 --- a/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx +++ b/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx @@ -37,7 +37,7 @@ vi.mock('../../lib/composio/hooks', () => ({ describe('Skills page — Notion composio integration', () => { it('renders Notion as a disconnected composio integration and opens its connect modal', async () => { renderWithProviders(, { initialEntries: ['/connections'] }); - fireEvent.click(screen.getByRole('tab', { name: 'Composio' })); + fireEvent.click(screen.getByTestId('two-pane-nav-composio')); expect(screen.getByRole('heading', { name: 'Composio Integrations' })).toBeInTheDocument(); const notionTile = screen.getByRole('button', { name: /Notion.*Connect/i }); diff --git a/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx b/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx index 13da4d072..9fe098b46 100644 --- a/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx +++ b/app/src/pages/conversations/components/TaskKanbanBoard.test.tsx @@ -200,9 +200,10 @@ describe('TaskKanbanBoard approval surface', () => { expect(openhumanTaskSourcesUpdate).toHaveBeenCalledWith('src-1', { enabled: false }) ); - // "Manage sources" jumps to the settings page. + // "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/task-sources'); + expect(navigateSpy).toHaveBeenCalledWith('/settings/integrations'); }); 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 053148ea1..9f6445ded 100644 --- a/app/src/pages/conversations/components/TaskKanbanBoard.tsx +++ b/app/src/pages/conversations/components/TaskKanbanBoard.tsx @@ -704,7 +704,7 @@ function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact:
diff --git a/app/src/pages/onboarding/customWizardSteps.ts b/app/src/pages/onboarding/customWizardSteps.ts index 5e3c71545..4427e1552 100644 --- a/app/src/pages/onboarding/customWizardSteps.ts +++ b/app/src/pages/onboarding/customWizardSteps.ts @@ -29,7 +29,7 @@ export const CUSTOM_WIZARD_ROUTES: Record = { export const CUSTOM_WIZARD_SETTINGS_ROUTES: Record = { inference: '/settings/llm', voice: '/settings/voice', - oauth: '/settings/composio-routing', + oauth: '/settings/integrations#composio', search: '/settings/tools', embeddings: '/settings/embeddings', activity: '/settings/activity-level', diff --git a/app/src/store/index.ts b/app/src/store/index.ts index e910e2101..846eccf34 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -26,6 +26,7 @@ import chatRuntimeReducer from './chatRuntimeSlice'; import companionReducer from './companionSlice'; import connectivityReducer from './connectivitySlice'; import coreModeReducer from './coreModeSlice'; +import layoutReducer from './layoutSlice'; import localeReducer from './localeSlice'; import mascotReducer from './mascotSlice'; import notificationReducer from './notificationSlice'; @@ -138,13 +139,14 @@ const persistedNotificationReducer = persistReducer(notificationPersistConfig, n // they were instead of falling through to "create a new thread". The // thread list and per-thread message caches are re-fetched from the core // on boot, so we deliberately don't persist them. -const threadPersistConfig = { - key: 'thread', - storage, - whitelist: ['selectedThreadId', 'threadSidebarVisible'], -}; +const threadPersistConfig = { key: 'thread', storage, whitelist: ['selectedThreadId'] }; const persistedThreadReducer = persistReducer(threadPersistConfig, threadReducer); +// Two-pane layout geometry (sidebar visibility + dragged widths), keyed by +// panel id. Persisted per user so the chat sidebar layout survives reloads. +const layoutPersistConfig = { key: 'layout', storage, whitelist: ['panels'] }; +const persistedLayoutReducer = persistReducer(layoutPersistConfig, layoutReducer); + // Persist only previously persisted mascot appearance fields plus the custom // GIF override added by this feature; leave existing non-persisted mascot // fields as runtime state to avoid changing refresh behavior. @@ -204,6 +206,7 @@ export const store = configureStore({ socket: socketReducer, connectivity: connectivityReducer, thread: persistedThreadReducer, + layout: persistedLayoutReducer, chatRuntime: persistedChatRuntimeReducer, companion: companionReducer, agentProfiles: agentProfileReducer, diff --git a/app/src/store/layoutSlice.test.ts b/app/src/store/layoutSlice.test.ts new file mode 100644 index 000000000..83441cd82 --- /dev/null +++ b/app/src/store/layoutSlice.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import layoutReducer, { + DEFAULT_PANEL_LAYOUT, + ensurePanelLayout, + selectPanelLayout, + setSidebarVisible, + setSidebarWidth, + toggleSidebar, +} from './layoutSlice'; +import { resetUserScopedState } from './resetActions'; + +describe('layoutSlice', () => { + it('starts with no panels', () => { + const state = layoutReducer(undefined, { type: '@@INIT' }); + expect(state.panels).toEqual({}); + }); + + it('seeds a panel from defaults on first ensure only', () => { + let state = layoutReducer(undefined, { type: '@@INIT' }); + state = layoutReducer( + state, + ensurePanelLayout({ id: 'chat', defaults: { sidebarWidth: 300 } }) + ); + expect(state.panels.chat).toEqual({ sidebarVisible: false, sidebarWidth: 300 }); + + // A second ensure must not clobber an existing layout. + state = layoutReducer(state, setSidebarWidth({ id: 'chat', width: 420 })); + state = layoutReducer( + state, + ensurePanelLayout({ id: 'chat', defaults: { sidebarWidth: 100 } }) + ); + expect(state.panels.chat.sidebarWidth).toBe(420); + }); + + it('toggles and sets visibility for a lazily-created panel', () => { + let state = layoutReducer(undefined, { type: '@@INIT' }); + state = layoutReducer(state, toggleSidebar({ id: 'chat' })); + expect(state.panels.chat.sidebarVisible).toBe(true); + state = layoutReducer(state, toggleSidebar({ id: 'chat' })); + expect(state.panels.chat.sidebarVisible).toBe(false); + + state = layoutReducer(state, setSidebarVisible({ id: 'chat', visible: true })); + expect(state.panels.chat.sidebarVisible).toBe(true); + }); + + it('persists width independently per panel id', () => { + let state = layoutReducer(undefined, { type: '@@INIT' }); + state = layoutReducer(state, setSidebarWidth({ id: 'chat', width: 320 })); + state = layoutReducer(state, setSidebarWidth({ id: 'files', width: 200 })); + expect(state.panels.chat.sidebarWidth).toBe(320); + expect(state.panels.files.sidebarWidth).toBe(200); + }); + + it('clears all panels on user-scoped reset', () => { + let state = layoutReducer(undefined, { type: '@@INIT' }); + state = layoutReducer(state, setSidebarWidth({ id: 'chat', width: 320 })); + state = layoutReducer(state, resetUserScopedState()); + expect(state.panels).toEqual({}); + }); + + describe('selectPanelLayout', () => { + it('falls back to defaults for an unseen id', () => { + const root = { layout: { panels: {} } }; + expect(selectPanelLayout('chat')(root)).toEqual(DEFAULT_PANEL_LAYOUT); + expect(selectPanelLayout('chat', { sidebarVisible: true })(root)).toEqual({ + ...DEFAULT_PANEL_LAYOUT, + sidebarVisible: true, + }); + }); + + it('returns persisted geometry when present', () => { + const root = { layout: { panels: { chat: { sidebarVisible: true, sidebarWidth: 333 } } } }; + expect(selectPanelLayout('chat')(root)).toEqual({ sidebarVisible: true, sidebarWidth: 333 }); + }); + }); +}); diff --git a/app/src/store/layoutSlice.ts b/app/src/store/layoutSlice.ts new file mode 100644 index 000000000..509c5744d --- /dev/null +++ b/app/src/store/layoutSlice.ts @@ -0,0 +1,90 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +import { resetUserScopedState } from './resetActions'; + +/** + * Persisted geometry for a single two-pane layout (see `TwoPanelLayout`). + * Keyed by an opaque panel `id` so multiple independent two-pane screens + * (chat today, others later) each remember their own sidebar state without + * colliding. + */ +export interface PanelLayout { + /** Whether the mini sidebar is shown. */ + sidebarVisible: boolean; + /** Sidebar width in CSS px, as set by dragging the divider. */ + sidebarWidth: number; +} + +interface LayoutState { + panels: Record; +} + +const initialState: LayoutState = { panels: {} }; + +/** + * Default geometry applied the first time a panel id is seen. Component-level + * defaults (passed as props) win on initial mount; this is only the fallback + * baked into the slice so reducers can operate without the component handy. + */ +export const DEFAULT_PANEL_LAYOUT: PanelLayout = { + sidebarVisible: false, + sidebarWidth: 256, // matches the legacy `w-64` thread sidebar +}; + +function panelFor(state: LayoutState, id: string): PanelLayout { + if (!state.panels[id]) { + state.panels[id] = { ...DEFAULT_PANEL_LAYOUT }; + } + return state.panels[id]; +} + +const layoutSlice = createSlice({ + name: 'layout', + initialState, + reducers: { + /** + * Seed a panel's geometry from the component's own defaults on first + * mount. No-op if the id already has persisted state, so a reload keeps + * the user's last layout rather than snapping back to the prop defaults. + */ + ensurePanelLayout: ( + state, + action: PayloadAction<{ id: string; defaults: Partial }> + ) => { + const { id, defaults } = action.payload; + if (!state.panels[id]) { + state.panels[id] = { ...DEFAULT_PANEL_LAYOUT, ...defaults }; + } + }, + setSidebarVisible: (state, action: PayloadAction<{ id: string; visible: boolean }>) => { + panelFor(state, action.payload.id).sidebarVisible = action.payload.visible; + }, + toggleSidebar: (state, action: PayloadAction<{ id: string }>) => { + const panel = panelFor(state, action.payload.id); + panel.sidebarVisible = !panel.sidebarVisible; + }, + setSidebarWidth: (state, action: PayloadAction<{ id: string; width: number }>) => { + panelFor(state, action.payload.id).sidebarWidth = action.payload.width; + }, + }, + extraReducers: builder => { + builder.addCase(resetUserScopedState, () => initialState); + }, +}); + +export const { ensurePanelLayout, setSidebarVisible, toggleSidebar, setSidebarWidth } = + layoutSlice.actions; + +/** + * Select a panel's geometry, falling back to `DEFAULT_PANEL_LAYOUT` (or the + * provided overrides) when the id has not been seen yet. Returns a stable + * shape so callers can destructure without null checks. + */ +export const selectPanelLayout = + (id: string, defaults?: Partial) => + (state: { layout?: LayoutState }): PanelLayout => + // Optional-chain `layout` so screens render even in minimal test stores + // that don't wire the (purely cosmetic) layout reducer. + state.layout?.panels[id] ?? { ...DEFAULT_PANEL_LAYOUT, ...defaults }; + +export default layoutSlice.reducer; diff --git a/app/src/store/threadSlice.ts b/app/src/store/threadSlice.ts index 7a9bf6c66..823bf43c8 100644 --- a/app/src/store/threadSlice.ts +++ b/app/src/store/threadSlice.ts @@ -11,7 +11,6 @@ export const THREAD_NOT_FOUND_MESSAGE = 'This thread is no longer available.'; interface ThreadState { threads: Thread[]; selectedThreadId: string | null; - threadSidebarVisible: boolean; /** * Set of threads that currently have an in-flight inference turn, keyed by * thread id. Replaces the legacy single `activeThreadId` so that turns on @@ -36,7 +35,6 @@ interface ThreadState { const initialState: ThreadState = { threads: [], selectedThreadId: null, - threadSidebarVisible: false, activeThreadIds: {}, welcomeThreadId: null, messagesByThreadId: {}, @@ -370,9 +368,6 @@ const threadSlice = createSlice({ clearThreadInferenceActive: (state, action: PayloadAction) => { delete state.activeThreadIds[action.payload]; }, - setThreadSidebarVisible: (state, action: PayloadAction) => { - state.threadSidebarVisible = action.payload; - }, clearStaleThread: (state, action: PayloadAction) => { const threadId = action.payload; state.threads = state.threads.filter(thread => thread.id !== threadId); @@ -497,7 +492,6 @@ export const { setActiveThread, markThreadInferenceActive, clearThreadInferenceActive, - setThreadSidebarVisible, clearStaleThread, clearAllThreads, resetThreadCachesPreservingSelection, diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts index 51f2b3cef..9a3c230c3 100644 --- a/app/src/test/setup.ts +++ b/app/src/test/setup.ts @@ -259,9 +259,14 @@ vi.mock('@sentry/react', () => ({ setUser: vi.fn(), })); -// Silence console during tests to keep output clean +// Silence console during tests to keep output clean. `debug`/`info` are +// included because error-path diagnostics across the app (e.g. VoicePanel +// "voice settings load failed", threadSlice "title refresh failed") use +// `console.debug`, which otherwise floods the test output with expected noise. if (!process.env.DEBUG_TESTS) { vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'info').mockImplementation(() => {}); + vi.spyOn(console, 'debug').mockImplementation(() => {}); vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); } diff --git a/app/src/test/test-utils.tsx b/app/src/test/test-utils.tsx index 4cafa2f7e..2de5886b0 100644 --- a/app/src/test/test-utils.tsx +++ b/app/src/test/test-utils.tsx @@ -15,6 +15,7 @@ import channelConnectionsReducer from '../store/channelConnectionsSlice'; import companionReducer from '../store/companionSlice'; import connectivityReducer from '../store/connectivitySlice'; import coreModeReducer from '../store/coreModeSlice'; +import layoutReducer from '../store/layoutSlice'; import localeReducer from '../store/localeSlice'; import mascotReducer from '../store/mascotSlice'; import personaReducer from '../store/personaSlice'; @@ -39,6 +40,7 @@ const testRootReducer = combineReducers({ companion: companionReducer, connectivity: connectivityReducer, coreMode: coreModeReducer, + layout: layoutReducer, locale: localeReducer, mascot: mascotReducer, persona: personaReducer, diff --git a/app/test/playwright/specs/composio-triggers-flow.spec.ts b/app/test/playwright/specs/composio-triggers-flow.spec.ts index 0297c61dc..35f7bd6ea 100644 --- a/app/test/playwright/specs/composio-triggers-flow.spec.ts +++ b/app/test/playwright/specs/composio-triggers-flow.spec.ts @@ -83,7 +83,7 @@ async function bootSkillsPage(page: Page, userId: string) { await waitForAppReady(page); await dismissWalkthroughIfPresent(page); // Navigate to the Composio tab - await page.getByRole('tab', { name: 'Composio' }).click(); + await page.getByTestId('two-pane-nav-composio').click(); // Heading reads "Composio Integrations" (skills.integrations); the tab is "Apps" await expect( page.getByRole('heading', { name: 'Composio Integrations', exact: true }) @@ -187,7 +187,7 @@ test.describe('Composio triggers flow', () => { await waitForAppReady(page); await dismissWalkthroughIfPresent(page); // Tab is "Apps"; heading reads "Composio Integrations" - await page.getByRole('tab', { name: 'Composio' }).click(); + await page.getByTestId('two-pane-nav-composio').click(); await expect( page.getByRole('heading', { name: 'Composio Integrations', exact: true }) ).toBeVisible({ timeout: 20_000 }); diff --git a/app/test/playwright/specs/connector-gmail-composio.spec.ts b/app/test/playwright/specs/connector-gmail-composio.spec.ts index 9067f9623..06b2bc2f5 100644 --- a/app/test/playwright/specs/connector-gmail-composio.spec.ts +++ b/app/test/playwright/specs/connector-gmail-composio.spec.ts @@ -76,7 +76,7 @@ async function bootSkillsPage(page: Page, userId: string) { await waitForAppReady(page); await dismissWalkthroughIfPresent(page); // Navigate to the Composio tab - await page.getByRole('tab', { name: 'Composio' }).click(); + await page.getByTestId('two-pane-nav-composio').click(); const heading = page.getByRole('heading', { name: 'Composio Integrations', exact: true }); if (!(await heading.isVisible().catch(() => false))) { const connectionsButton = page.getByRole('button', { name: 'Connections' }); @@ -110,7 +110,7 @@ async function ensureComposioSurface(page: Page) { .toContain('/connections'); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - await page.getByRole('tab', { name: 'Composio' }).click(); + await page.getByTestId('two-pane-nav-composio').click(); if (await heading.isVisible().catch(() => false)) { return; } @@ -119,7 +119,7 @@ async function ensureComposioSurface(page: Page) { await connectionsButton.click({ force: true }); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - await page.getByRole('tab', { name: 'Composio' }).click(); + await page.getByTestId('two-pane-nav-composio').click(); if (await heading.isVisible().catch(() => false)) { return; } diff --git a/app/test/playwright/specs/connector-session-guard-matrix.spec.ts b/app/test/playwright/specs/connector-session-guard-matrix.spec.ts index 1bd9c5ff9..9a0b72961 100644 --- a/app/test/playwright/specs/connector-session-guard-matrix.spec.ts +++ b/app/test/playwright/specs/connector-session-guard-matrix.spec.ts @@ -73,7 +73,7 @@ async function bootSkills(page: Page, userId: string): Promise { await page.goto('/#/connections'); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - await page.getByRole('tab', { name: 'Composio' }).click(); + await page.getByTestId('two-pane-nav-composio').click(); await expect( page.getByRole('heading', { name: 'Composio Integrations', exact: true }) ).toBeVisible({ timeout: 20_000 }); @@ -149,7 +149,7 @@ test.describe('Connector session guard matrix', () => { await page.reload(); await waitForAppReady(page); // Phase 2: "Composio" tab renamed to "Apps" - await page.getByRole('tab', { name: 'Composio' }).click(); + await page.getByTestId('two-pane-nav-composio').click(); await page.getByTestId('skill-install-composio-jira').click(); const dialog = page.getByRole('dialog', { name: /Jira/i }); await expect(dialog).toBeVisible(); @@ -163,7 +163,7 @@ test.describe('Connector session guard matrix', () => { await page.reload(); await waitForAppReady(page); // Phase 2: "Composio" tab renamed to "Apps" - await page.getByRole('tab', { name: 'Composio' }).click(); + await page.getByTestId('two-pane-nav-composio').click(); await expect(page.getByTestId('skill-install-composio-discord')).toContainText('Discord'); await assertSessionAlive(page); @@ -171,7 +171,7 @@ test.describe('Connector session guard matrix', () => { await page.reload(); await waitForAppReady(page); // Phase 2: "Composio" tab renamed to "Apps" - await page.getByRole('tab', { name: 'Composio' }).click(); + await page.getByTestId('two-pane-nav-composio').click(); await expect(page.getByTestId('skill-install-composio-github')).toContainText( /Reconnect|GitHub/ ); diff --git a/app/test/playwright/specs/crypto-payment-flow.spec.ts b/app/test/playwright/specs/crypto-payment-flow.spec.ts index 7e9a07607..c9252f1a0 100644 --- a/app/test/playwright/specs/crypto-payment-flow.spec.ts +++ b/app/test/playwright/specs/crypto-payment-flow.spec.ts @@ -19,14 +19,12 @@ test.describe('Crypto Payment Flow', () => { await expect(page.getByRole('button', { name: 'Open billing dashboard' })).toBeVisible(); }); - test('opening-browser status copy is shown on mount', async ({ page }) => { + test('shows the moved-to-web explanation on mount', async ({ page }) => { await waitForAppReady(page); + // Billing no longer auto-opens the browser on mount; the panel explains that + // billing moved to the web instead of showing an "opening browser" status. await expect( - page - .getByText( - /Opening your browser|If your browser did not open, use the button above\.|The browser could not be opened automatically\./ - ) - .first() + page.getByText(/Subscription changes, payment methods, credits, and invoices are now managed/) ).toBeVisible(); }); }); diff --git a/app/test/playwright/specs/gmail-flow.spec.ts b/app/test/playwright/specs/gmail-flow.spec.ts index 1ea0cb77f..8c624729b 100644 --- a/app/test/playwright/specs/gmail-flow.spec.ts +++ b/app/test/playwright/specs/gmail-flow.spec.ts @@ -71,7 +71,7 @@ async function bootSkillsPage(page: Page, userId: string) { await waitForAppReady(page); await dismissWalkthroughIfPresent(page); // Tab is "Apps"; the h2 heading reads "Composio Integrations" (skills.integrations) - await page.getByRole('tab', { name: 'Composio' }).click(); + await page.getByTestId('two-pane-nav-composio').click(); // Wait for the Apps tab grid to be visible — the h2 heading text is "Composio Integrations" const heading = page.getByRole('heading', { name: 'Composio Integrations', exact: true }); await expect(heading).toBeVisible({ timeout: 20_000 }); @@ -99,7 +99,7 @@ test.describe('Gmail Integration Flows', () => { await waitForAppReady(page); await dismissWalkthroughIfPresent(page); // Phase 2: "Composio" tab renamed to "Apps" - await page.getByRole('tab', { name: 'Composio' }).click(); + await page.getByTestId('two-pane-nav-composio').click(); await page.getByTestId('skill-install-composio-gmail').click(); await expect(page.getByRole('dialog', { name: /Connect Gmail/i })).toBeVisible(); @@ -126,14 +126,14 @@ test.describe('Gmail Integration Flows', () => { await page.reload(); await waitForAppReady(page); // Phase 2: "Composio" tab renamed to "Apps" - await page.getByRole('tab', { name: 'Composio' }).click(); + await page.getByTestId('two-pane-nav-composio').click(); await expect(page.getByTestId('skill-install-composio-gmail')).toContainText(CONNECTOR_NAME); await seedConnector('EXPIRED'); await page.reload(); await waitForAppReady(page); // Phase 2: "Composio" tab renamed to "Apps" - await page.getByRole('tab', { name: 'Composio' }).click(); + await page.getByTestId('two-pane-nav-composio').click(); await expect(page.getByTestId(`skill-install-composio-${TOOLKIT_SLUG}`)).toContainText( /Auth expired|Reconnect/i ); @@ -142,7 +142,7 @@ test.describe('Gmail Integration Flows', () => { test('execute and disconnect routes do not blank the skills page', async ({ page }) => { await callCoreRpc('openhuman.composio_execute', { tool: ACTION, arguments: {} }); // Tab is "Apps"; the h2 heading reads "Composio Integrations" (skills.integrations) - await page.getByRole('tab', { name: 'Composio' }).click(); + await page.getByTestId('two-pane-nav-composio').click(); await expect( page.getByRole('heading', { name: 'Composio Integrations', exact: true }) ).toBeVisible(); diff --git a/app/test/playwright/specs/gmeet-connections-tab.spec.ts b/app/test/playwright/specs/gmeet-connections-tab.spec.ts index 795ca0982..15b353048 100644 --- a/app/test/playwright/specs/gmeet-connections-tab.spec.ts +++ b/app/test/playwright/specs/gmeet-connections-tab.spec.ts @@ -18,10 +18,7 @@ test.describe('Google Meet Connections tab', () => { .poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 }) .toContain('/connections'); - await expect(page.getByRole('tab', { name: 'Meetings', exact: true })).toHaveAttribute( - 'aria-selected', - 'true' - ); + await expect(page.getByTestId('two-pane-nav-meetings')).toHaveAttribute('aria-current', 'page'); // The join form renders inline on the Meetings tab (no banner/modal). await expect(page.getByText('Send OpenHuman to a meeting')).toBeVisible(); diff --git a/app/test/playwright/specs/navigation.spec.ts b/app/test/playwright/specs/navigation.spec.ts index ba93a5fca..460616942 100644 --- a/app/test/playwright/specs/navigation.spec.ts +++ b/app/test/playwright/specs/navigation.spec.ts @@ -12,16 +12,16 @@ interface RouteEntry { // Back-compat redirects are included so the router redirect itself is tested. // /human → renders the Human surface (first-class route, restored) // /skills → /connections (Phase 2) -// /activity → /settings/notifications-hub (Phase 6) -// /intelligence → /settings/notifications-hub (Phase 6) +// /activity → /settings/notifications (Phase 6) +// /intelligence → /settings/notifications (Phase 6) const ROUTES: RouteEntry[] = [ { route: '/home' }, { route: '/human' }, // first-class route again (no longer redirects to /chat) { route: '/chat' }, { route: '/connections' }, { route: '/skills', expectedHash: '/connections' }, // back-compat redirect - { route: '/activity', expectedHash: '/settings/notifications-hub' }, // back-compat redirect - { route: '/intelligence', expectedHash: '/settings/notifications-hub' }, // back-compat redirect + { route: '/activity', expectedHash: '/settings/notifications' }, // back-compat redirect + { route: '/intelligence', expectedHash: '/settings/notifications' }, // back-compat redirect { route: '/rewards' }, { route: '/settings' }, ]; diff --git a/app/test/playwright/specs/settings-account-preferences.spec.ts b/app/test/playwright/specs/settings-account-preferences.spec.ts index 9274760f5..8a29bfff7 100644 --- a/app/test/playwright/specs/settings-account-preferences.spec.ts +++ b/app/test/playwright/specs/settings-account-preferences.spec.ts @@ -35,21 +35,25 @@ test.describe('Settings - Account Preferences', () => { await gotoSettingsRoute(page, '/settings/account'); await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible(); - await expect(page.getByTestId('settings-nav-team')).toBeVisible(); - await expect(page.getByTestId('settings-nav-privacy')).toBeVisible(); - await expect(page.getByTestId('settings-nav-migration')).toBeVisible(); - // Recovery phrase + wallet balances moved out of Account into the Crypto hub. - await expect(page.getByTestId('settings-nav-recovery-phrase')).toHaveCount(0); + // The Account family surfaces its leaves via the sub-nav pill row above the + // panel (the two-pane sidebar replaced the old section-hub list). + await expect(page.getByTestId('settings-subnav-team')).toBeVisible(); + await expect(page.getByTestId('settings-subnav-privacy')).toBeVisible(); + await expect(page.getByTestId('settings-subnav-migration')).toBeVisible(); + // Recovery phrase + wallet balances live under the Wallet family, not Account. + await expect(page.getByTestId('settings-subnav-recovery-phrase')).toHaveCount(0); }); test('renders the crypto settings section route with recovery phrase + balances', async ({ page, }) => { + // /settings/crypto is retired and redirects to the Wallet Balances panel, + // whose sub-nav family surfaces recovery-phrase + wallet-balances. await gotoSettingsRoute(page, '/settings/crypto'); - await expect(page.getByRole('heading', { name: 'Crypto' })).toBeVisible(); - await expect(page.getByTestId('settings-nav-recovery-phrase')).toBeVisible(); - await expect(page.getByTestId('settings-nav-wallet-balances')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Wallet Balances' })).toBeVisible(); + await expect(page.getByTestId('settings-subnav-recovery-phrase')).toBeVisible(); + await expect(page.getByTestId('settings-subnav-wallet-balances')).toBeVisible(); }); test('saves a generated recovery phrase and exposes configured wallet state', async ({ @@ -101,25 +105,39 @@ test.describe('Settings - Account Preferences', () => { await expect(page.getByRole('heading', { name: 'Privacy & Security' })).toBeVisible(); await expect(page.getByText('Share Product Analytics and Diagnostics')).toBeVisible(); + // Toggle + confirm each setting sequentially. Clicking both back-to-back and + // polling for the combined result is racy: each toggle triggers an async + // save and panel re-render, so the second click can land before the first + // settles, dropping one update. Also wait for each switch to reflect the + // persisted initial state before clicking — the panel can render from a + // not-yet-synced snapshot, and clicking then computes the wrong new value. + await expect(page.getByTestId('privacy-analytics-toggle')).toBeChecked({ + checked: initialAnalytics, + }); await page.getByTestId('privacy-analytics-toggle').click(); - await page.getByTestId('privacy-meet-handoff-toggle').click(); - await expect .poll(async () => { const analytics = await callCoreRpc<{ result?: { enabled?: boolean } }>( 'openhuman.config_get_analytics_settings', {} ); + return Boolean(analytics.result?.enabled); + }) + .toBe(!initialAnalytics); + + await expect(page.getByTestId('privacy-meet-handoff-toggle')).toBeChecked({ + checked: initialMeet, + }); + await page.getByTestId('privacy-meet-handoff-toggle').click(); + await expect + .poll(async () => { const meet = await callCoreRpc<{ result?: { auto_orchestrator_handoff?: boolean } }>( 'openhuman.config_get_meet_settings', {} ); - return { - analyticsEnabled: Boolean(analytics.result?.enabled), - meetHandoff: Boolean(meet.result?.auto_orchestrator_handoff), - }; + return Boolean(meet.result?.auto_orchestrator_handoff); }) - .toEqual({ analyticsEnabled: !initialAnalytics, meetHandoff: !initialMeet }); + .toBe(!initialMeet); const snapshot = await callCoreRpc<{ result?: { analyticsEnabled?: boolean; meetAutoOrchestratorHandoff?: boolean }; @@ -132,10 +150,10 @@ test.describe('Settings - Account Preferences', () => { await gotoSettingsRoute(page, '/settings/billing'); await expect(page.getByRole('heading', { name: 'Open billing dashboard' })).toBeVisible(); + // Billing no longer auto-opens the browser; the panel explains billing + // moved to the web and offers an explicit open button. await expect( - page.getByText( - /If your browser did not open, use the button above\.|The browser could not be opened automatically\.|Opening your browser\.\.\./ - ) + page.getByText(/Subscription changes, payment methods, credits, and invoices are now managed/) ).toBeVisible(); await page.getByRole('button', { name: 'Back to settings' }).click(); diff --git a/app/test/playwright/specs/settings-advanced-config.spec.ts b/app/test/playwright/specs/settings-advanced-config.spec.ts index e9066f3cd..242d33dfb 100644 --- a/app/test/playwright/specs/settings-advanced-config.spec.ts +++ b/app/test/playwright/specs/settings-advanced-config.spec.ts @@ -62,9 +62,10 @@ test.describe('Settings - Advanced Config', () => { await expect(page.getByRole('heading', { name: 'Developer & Diagnostics' })).toBeVisible(); // Developer Options is debug-only now: user-facing sections (AI, Integrations…) // live on their section pages, so Developer Options surfaces diagnostics entries. - await expect(page.getByTestId('settings-nav-memory-debug')).toBeVisible(); - await expect(page.getByTestId('settings-nav-event-log')).toBeVisible(); - await expect(page.getByTestId('settings-nav-build-info')).toBeVisible(); + // The two-pane sidebar may also surface these ids, so scope to the first match. + await expect(page.getByTestId('settings-nav-memory-debug').first()).toBeVisible(); + await expect(page.getByTestId('settings-nav-event-log').first()).toBeVisible(); + await expect(page.getByTestId('settings-nav-build-info').first()).toBeVisible(); }); test('persists notification routing settings through core RPC', async ({ page }) => { @@ -119,9 +120,11 @@ test.describe('Settings - Advanced Config', () => { const current = before.result?.max_actions_per_hour ?? 20; const target = current === 250 ? 251 : 250; + // /settings/autonomy redirects to Agent access, which hosts the autonomy + // rate-limit section (Max actions per hour). await gotoSettingsRoute(page, '/settings/autonomy'); - await expect(page.getByText('Agent autonomy')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Max actions per hour' })).toBeVisible(); await page.locator('#autonomy-max-actions').fill(String(target)); await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('Saved.')).toBeVisible(); @@ -195,13 +198,17 @@ test.describe('Settings - Advanced Config', () => { test('mounts the remaining advanced settings routes', async ({ page }) => { await gotoSettingsRoute(page, '/settings/local-model-debug'); - await expect(page.getByText('Local Model Debug')).toBeVisible(); + // The two-pane sidebar also renders this label, so scope to the first match. + await expect(page.getByText('Local Model Debug').first()).toBeVisible(); await gotoSettingsRoute(page, '/settings/about'); await expect(page.getByText('Software updates')).toBeVisible(); + // /settings/llm now redirects to the Connections page (LLM moved there). await gotoSettingsRoute(page, '/settings/llm'); - await expect(page.getByRole('button', { name: 'AI & Models', exact: true })).toBeVisible(); + await expect + .poll(async () => page.evaluate(() => window.location.hash)) + .toContain('/connections'); await expect(page.getByText(/Reasoning|Cloud providers|OpenHuman/).first()).toBeVisible(); }); }); diff --git a/app/test/playwright/specs/settings-ai-skills.spec.ts b/app/test/playwright/specs/settings-ai-skills.spec.ts index 4a35a094b..51ca04113 100644 --- a/app/test/playwright/specs/settings-ai-skills.spec.ts +++ b/app/test/playwright/specs/settings-ai-skills.spec.ts @@ -12,11 +12,15 @@ test.describe('Settings - AI & Skills', () => { }); test('mounts LLM panel and shows provider/routing controls', async ({ page }) => { + // /settings/llm now redirects to the Connections page (LLM moved there); + // the AIPanel renders on the Connections LLM tab. await page.goto('/#/settings/llm'); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - await expect(page.getByRole('button', { name: 'AI & Models', exact: true })).toBeVisible(); + await expect + .poll(async () => page.evaluate(() => window.location.hash)) + .toContain('/connections'); await expect(page.getByRole('heading', { name: 'LLM Providers', exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Routing', exact: true })).toBeVisible(); }); @@ -26,7 +30,8 @@ test.describe('Settings - AI & Skills', () => { await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - await expect(page.getByText('Tools')).toBeVisible(); + // The two-pane sidebar also renders a "Tools" nav label, so scope to first. + await expect(page.getByText('Tools').first()).toBeVisible(); await expect(page.getByText(/Filesystem|Shell/).first()).toBeVisible(); }); }); diff --git a/app/test/playwright/specs/settings-channels-permissions.spec.ts b/app/test/playwright/specs/settings-channels-permissions.spec.ts index 91c912f42..5d74ba58c 100644 --- a/app/test/playwright/specs/settings-channels-permissions.spec.ts +++ b/app/test/playwright/specs/settings-channels-permissions.spec.ts @@ -32,7 +32,7 @@ test.describe('Settings - Channels & Permissions', () => { await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - const messagingTab = page.getByRole('tab', { name: 'Channels', exact: true }); + const messagingTab = page.getByTestId('two-pane-nav-channels'); if (await messagingTab.isVisible().catch(() => false)) { await messagingTab.click(); } diff --git a/app/test/playwright/specs/settings-feature-preferences.spec.ts b/app/test/playwright/specs/settings-feature-preferences.spec.ts index 934ea17e5..e73b50584 100644 --- a/app/test/playwright/specs/settings-feature-preferences.spec.ts +++ b/app/test/playwright/specs/settings-feature-preferences.spec.ts @@ -88,13 +88,14 @@ function readEnabledTools(snapshot: ToolsSnapshot): string[] { test.describe('Settings - Feature Preferences', () => { test('renders the features settings section route', async ({ page }) => { + // The old "Features" hub page is retired and redirects to + // /settings/screen-intelligence; its destinations are sidebar entries now. await openAuthenticatedRoute(page, 'pw-settings-features-route', '/settings/features'); - await expect(page.getByText('Features', { exact: true })).toBeVisible(); + await expect + .poll(async () => page.evaluate(() => window.location.hash)) + .toContain('/settings/screen-intelligence'); await expect(page.getByTestId('settings-nav-screen-intelligence')).toBeVisible(); - // Phase 2: default messaging channel moved to /connections (Messaging tab). - // Settings consistency pass: Notifications now has its own home-level hub - // (notifications-hub) and is no longer nested under the Features section. await expect(page.getByTestId('settings-nav-tools')).toBeVisible(); await expect(page.getByTestId('settings-nav-companion')).toBeVisible(); }); @@ -103,7 +104,7 @@ test.describe('Settings - Feature Preferences', () => { // Phase 2: default messaging channel moved to /connections (Messaging tab) await openAuthenticatedRoute(page, 'pw-settings-default-channel', '/connections?tab=messaging'); - const messagingTab = page.getByRole('tab', { name: 'Channels', exact: true }); + const messagingTab = page.getByTestId('two-pane-nav-channels'); if (await messagingTab.isVisible().catch(() => false)) { await messagingTab.click(); } @@ -137,7 +138,8 @@ test.describe('Settings - Feature Preferences', () => { await reloadAndWait(page); - await expect(page.getByText('Tools', { exact: true })).toBeVisible(); + // The two-pane sidebar also renders a "Tools" nav label, so scope to first. + await expect(page.getByText('Tools', { exact: true }).first()).toBeVisible(); // Tool rows are now SettingsRow + SettingsSwitch (role="switch", aria-label = // the tool's display name), not a single text-bearing button. const shellToggle = page.getByRole('switch', { name: 'Shell Commands', exact: true }); diff --git a/app/test/playwright/specs/skills-registry.spec.ts b/app/test/playwright/specs/skills-registry.spec.ts index 1e997f906..1003b1795 100644 --- a/app/test/playwright/specs/skills-registry.spec.ts +++ b/app/test/playwright/specs/skills-registry.spec.ts @@ -33,10 +33,10 @@ test.describe('Skills registry flow', () => { }); test('navigates to /connections and renders the current tabs', async ({ page }) => { - await expect(page.getByRole('tab', { name: 'Composio', exact: true })).toBeVisible(); - await expect(page.getByRole('tab', { name: 'Channels', exact: true })).toBeVisible(); - await expect(page.getByRole('tab', { name: 'MCP Servers', exact: true })).toBeVisible(); - await page.getByRole('tab', { name: 'Composio', exact: true }).click(); + await expect(page.getByTestId('two-pane-nav-composio')).toBeVisible(); + await expect(page.getByTestId('two-pane-nav-channels')).toBeVisible(); + await expect(page.getByTestId('two-pane-nav-mcp')).toBeVisible(); + await page.getByTestId('two-pane-nav-composio').click(); await expect( page.getByRole('heading', { name: 'Composio Integrations', exact: true }) ).toBeVisible(); @@ -52,12 +52,12 @@ test.describe('Skills registry flow', () => { }); test('channels tab renders messaging connectors', async ({ page }) => { - await page.getByRole('tab', { name: 'Channels', exact: true }).click(); + await page.getByTestId('two-pane-nav-channels').click(); await expect(page.getByText(/Telegram|Discord|Slack/).first()).toBeVisible(); }); test('MCP Servers tab renders the server table', async ({ page }) => { - await page.getByRole('tab', { name: 'MCP Servers', exact: true }).click(); + await page.getByTestId('two-pane-nav-mcp').click(); await expect( page .getByRole('searchbox') diff --git a/app/test/playwright/specs/webhooks-tunnel-flow.spec.ts b/app/test/playwright/specs/webhooks-tunnel-flow.spec.ts index c4ac1fc60..31c2833a4 100644 --- a/app/test/playwright/specs/webhooks-tunnel-flow.spec.ts +++ b/app/test/playwright/specs/webhooks-tunnel-flow.spec.ts @@ -106,9 +106,10 @@ test.describe('Webhook tunnel CRUD (UI + core RPC + mock backend)', () => { await page.goto('/#/settings/webhooks-triggers'); await waitForAppReady(page); + // webhooks-triggers was merged into the Integrations page (#webhooks tab). await expect .poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 }) - .toContain('/settings/webhooks-triggers'); + .toContain('/settings/integrations'); const text = await page.locator('#root').innerText(); expect(