feat(ui): TwoPanelLayout shell for Settings, Brain, Connections, Chat (#3643)

This commit is contained in:
Steven Enamakel
2026-06-12 20:26:51 -07:00
committed by GitHub
parent 03678e3b6d
commit 190329c2c3
103 changed files with 4861 additions and 4122 deletions
+4 -4
View File
@@ -87,9 +87,9 @@ const AppRoutes = () => {
}
/>
{/* Back-compat: /activity and /intelligence → settings notifications hub. */}
<Route path="/activity" element={<Navigate to="/settings/notifications-hub" replace />} />
<Route path="/intelligence" element={<Navigate to="/settings/notifications-hub" replace />} />
{/* Back-compat: /activity and /intelligence → settings notifications page. */}
<Route path="/activity" element={<Navigate to="/settings/notifications" replace />} />
<Route path="/intelligence" element={<Navigate to="/settings/notifications" replace />} />
{/* 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 = () => {
<Route path="/workflows" element={<Navigate to="/settings/automations" replace />} />
<Route path="/webhooks" element={<Navigate to="/settings/webhooks-triggers" replace />} />
<Route path="/webhooks" element={<Navigate to="/settings/integrations#webhooks" replace />} />
<Route
path="/settings/*"
+4 -8
View File
@@ -191,15 +191,11 @@ const BottomTabBar = () => {
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;
};
@@ -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 (
<div className="z-10 relative" data-testid="cost-dashboard-panel">
<SettingsHeader
title={t('settings.costDashboard.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
{!embedded && (
<SettingsHeader
title={t('settings.costDashboard.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<div className="p-4 space-y-4">
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-neutral-500 dark:text-neutral-400 max-w-prose">
@@ -799,7 +799,7 @@ function TaskSourceTaskList({
</div>
<button
type="button"
onClick={() => navigate('/settings/task-sources')}
onClick={() => navigate('/settings/integrations')}
className="text-xs font-medium text-ocean-600 hover:text-ocean-700 dark:text-ocean-300 dark:hover:text-ocean-200">
{t('conversations.taskKanban.sources.manage')}
</button>
@@ -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 () => {
+85
View File
@@ -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 (
<nav aria-label={ariaLabel} className="flex h-full flex-col">
{header && <div className="shrink-0 px-3 pb-1 pt-3">{header}</div>}
<div className="min-h-0 flex-1 overflow-y-auto px-1.5 pb-2">
{groups.map((group, groupIndex) => (
<div key={group.label ?? `__group-${groupIndex}`}>
{group.label && (
<div className="px-2 pb-0.5 pt-2.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{group.label}
</span>
</div>
)}
<ul>
{group.items.map(item => {
const active = item.value === selected;
return (
<li key={item.value}>
<button
type="button"
data-testid={`two-pane-nav-${item.value}`}
aria-current={active ? 'page' : undefined}
onClick={() => onSelect(item.value)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] transition-colors ${
active
? 'bg-stone-100 font-medium text-stone-900 dark:bg-neutral-800 dark:text-neutral-100'
: 'text-stone-600 hover:bg-stone-50 hover:text-stone-900 dark:text-neutral-300 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-100'
}`}>
<span
className={`shrink-0 ${
active
? 'text-primary-600 dark:text-primary-400'
: 'text-stone-400 dark:text-neutral-500'
}`}>
{item.icon ?? null}
</span>
<span className="truncate">{item.label}</span>
</button>
</li>
);
})}
</ul>
</div>
))}
</div>
</nav>
);
}
@@ -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 <div>sidebar-content</div>;
}
function Content() {
return <div>main-content</div>;
}
describe('TwoPanelLayout', () => {
it('renders only the content when the sidebar is hidden', () => {
renderWithProviders(
<TwoPanelLayout id="chat" sidebar={<Sidebar />}>
<Content />
</TwoPanelLayout>,
{
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(
<TwoPanelLayout id="chat" sidebar={<Sidebar />}>
<Content />
</TwoPanelLayout>,
{
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(
<TwoPanelLayout id="chat" sidebar={<Sidebar />} forceSidebarVisible>
<Content />
</TwoPanelLayout>,
{
preloadedState: {
layout: { panels: { chat: { sidebarVisible: false, sidebarWidth: 256 } } },
},
}
);
expect(screen.getByText('sidebar-content')).toBeInTheDocument();
expect(
(store.getState() as { layout: { panels: Record<string, unknown> } }).layout.panels.chat
).toEqual({ sidebarVisible: false, sidebarWidth: 256 });
});
it('resizes via keyboard and clamps to the configured bounds', () => {
const { store } = renderWithProviders(
<TwoPanelLayout id="chat" sidebar={<Sidebar />} minSidebarWidth={180} maxSidebarWidth={480}>
<Content />
</TwoPanelLayout>,
{
preloadedState: {
layout: { panels: { chat: { sidebarVisible: true, sidebarWidth: 300 } } },
},
}
);
const divider = screen.getByTestId('two-panel-divider-chat');
const widthOf = () =>
(store.getState() as { layout: { panels: Record<string, { sidebarWidth: number }> } }).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(
<TwoPanelLayout
id="fresh"
sidebar={<Sidebar />}
defaultSidebarVisible
defaultSidebarWidth={222}>
<Content />
</TwoPanelLayout>
);
const panel = (store.getState() as { layout: { panels: Record<string, unknown> } }).layout
.panels.fresh;
expect(panel).toEqual({ sidebarVisible: true, sidebarWidth: 222 });
});
it('shows a reopen rail when collapsed and showCollapsedRail is set', () => {
const { store } = renderWithProviders(
<TwoPanelLayout id="chat" sidebar={<Sidebar />} showCollapsedRail>
<Content />
</TwoPanelLayout>,
{
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<string, { sidebarVisible: boolean }> } })
.layout.panels.chat.sidebarVisible
).toBe(true);
});
});
describe('useTwoPanelLayout', () => {
function Harness() {
const { sidebarVisible, toggleSidebar } = useTwoPanelLayout('chat', { sidebarVisible: false });
return (
<button type="button" onClick={toggleSidebar}>
{sidebarVisible ? 'open' : 'closed'}
</button>
);
}
it('reflects and toggles the same persisted state', () => {
renderWithProviders(<Harness />);
const btn = screen.getByRole('button');
expect(btn).toHaveTextContent('closed');
fireEvent.click(btn);
expect(btn).toHaveTextContent('open');
fireEvent.click(btn);
expect(btn).toHaveTextContent('closed');
});
});
@@ -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<string, unknown>) {
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<PanelLayout>) {
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<number | null>(null);
const dragWidthRef = useRef<number | null>(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 (
<div className={`flex min-h-0 ${className}`}>
{isOpen && (
<>
<div
className={`flex-shrink-0 min-w-0 overflow-hidden ${paneClassName} ${sidebarClassName}`}
style={{ width }}
data-testid={`two-panel-sidebar-${id}`}>
{sidebar}
</div>
{/* Drag handle / divider */}
<div
role="separator"
aria-orientation="vertical"
aria-label={t('layout.resizeSidebar')}
aria-valuenow={Math.round(width)}
aria-valuemin={minSidebarWidth}
aria-valuemax={maxSidebarWidth}
tabIndex={0}
data-testid={`two-panel-divider-${id}`}
data-analytics-id="two-panel-resize-divider"
onPointerDown={onPointerDown}
onKeyDown={onDividerKeyDown}
className={`group relative flex flex-shrink-0 cursor-col-resize select-none items-center justify-center self-stretch focus:outline-none ${
// Tighter gutter between panes when there's no visible handle.
showDividerHandle ? 'mx-1 w-3' : 'mx-0 w-1.5'
}`}
title={t('layout.resizeSidebar')}>
{/* 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. */}
<span
className={`h-10 w-1 rounded-full transition-colors group-hover:bg-primary-400 group-focus:bg-primary-500 ${
showDividerHandle ? 'bg-stone-400 dark:bg-neutral-500' : 'bg-transparent'
}`}
/>
</div>
</>
)}
{!isOpen && showCollapsedRail && (
<button
type="button"
data-testid={`two-panel-reopen-${id}`}
data-analytics-id="two-panel-reopen-sidebar"
onClick={() => dispatch(setSidebarVisible({ id, visible: true }))}
title={t('layout.showSidebar')}
aria-label={t('layout.showSidebar')}
className="flex-shrink-0 w-6 self-stretch flex items-center justify-center text-stone-400 dark:text-neutral-500 hover:text-primary-500 hover:bg-stone-50 dark:hover:bg-neutral-800/60 transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
<div className={`flex-1 min-w-0 overflow-hidden ${paneClassName} ${contentClassName}`}>
{children}
</div>
</div>
);
}
@@ -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 = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
);
const LanguageIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
/>
</svg>
);
const AppearanceIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"
/>
</svg>
);
const DevicesIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
);
const PersonalityIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
);
const MascotIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="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"
/>
</svg>
);
const NotificationsIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="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"
/>
</svg>
);
const DeveloperIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
/>
</svg>
);
const AboutIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
const DataSyncIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="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"
/>
</svg>
);
const AiIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="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"
/>
</svg>
);
const AgentsIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="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"
/>
</svg>
);
const FeaturesIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="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.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
const IntegrationsIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
);
const CryptoIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V6m0 10c-1.11 0-2.08-.402-2.599-1M12 16v2m0-12a9 9 0 100 18 9 9 0 000-18z"
/>
</svg>
);
// ---------------------------------------------------------------------------
// Group header (visual separator label above each settings card)
// ---------------------------------------------------------------------------
const GroupHeader = ({ label }: { label: string }) =>
label ? (
<div className="px-1 pt-5 pb-1">
<span className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{label}
</span>
</div>
) : (
// Empty label → a plain divider (Developer & Diagnostics and About sit
// after a divider, not under their own section headers).
<div className="mx-1 mt-6 mb-2 border-t border-stone-200 dark:border-neutral-800" />
);
// ---------------------------------------------------------------------------
// 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: <LanguageSelect ariaLabel={t('settings.language')} />,
},
{
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 (
<div className="z-10 relative">
<div data-walkthrough="settings-menu">
<SettingsHeader />
</div>
<SettingsSearchBar value={searchQuery} onValueChange={setSearchQuery} />
{/* While searching, the search bar renders its own results and the normal
settings menu is hidden to avoid a confusing double list. */}
{isSearching ? null : (
<div className="px-4 pt-3 pb-5">
{/* Merged layman card — Account / Assistant / Features & Integrations /
Notifications / Crypto in one flat card. No sub-section headers. */}
<div
data-testid="settings-group-main"
className="rounded-3xl overflow-hidden border border-stone-200 dark:border-neutral-800">
{laymanItems.map((item, index) => (
<SettingsMenuItem
key={item.id}
icon={item.icon}
title={item.title}
description={item.description}
onClick={item.onClick}
testId={`settings-nav-${item.id}`}
dangerous={item.dangerous}
isFirst={index === 0}
isLast={index === laymanItems.length - 1}
rightElement={item.rightElement}
/>
))}
</div>
{trailingGroups.map(group => (
<div key={group.id} data-testid={`settings-group-${group.id}`}>
<GroupHeader label={group.label} />
<div className="rounded-3xl overflow-hidden border border-stone-200 dark:border-neutral-800">
{group.items.map((item, index) => (
<SettingsMenuItem
key={item.id}
icon={item.icon}
title={item.title}
description={item.description}
onClick={item.onClick}
testId={`settings-nav-${item.id}`}
dangerous={item.dangerous}
isFirst={index === 0}
isLast={index === group.items.length - 1}
rightElement={item.rightElement}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
);
};
export default SettingsHome;
@@ -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 (
<div className="z-10 relative">
<SettingsHeader
title={title}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
{/* 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. */}
<div className="px-4 pb-5">
{description && (
<p className="mb-3 px-1 text-xs text-stone-500 dark:text-neutral-400">{description}</p>
)}
<div className="rounded-3xl overflow-hidden border border-stone-200 dark:border-neutral-800">
{items.map((item, index) => (
<SettingsMenuItem
key={item.id}
icon={item.icon}
title={item.title}
description={item.description}
onClick={item.onClick ?? (() => item.route && navigateToSettings(item.route))}
testId={`settings-nav-${item.id}`}
isFirst={index === 0}
isLast={index === items.length - 1}
/>
))}
</div>
{footer && (
<>
{/* Divider + card, mirroring how SettingsHome separates its
trailing groups (e.g. the destructive logout/clear card). */}
<div className="mx-1 mt-6 mb-2 border-t border-stone-200 dark:border-neutral-800" />
<div className="rounded-3xl overflow-hidden border border-stone-200 dark:border-neutral-800">
{footer}
</div>
</>
)}
</div>
</div>
);
};
export default SettingsSectionPage;
@@ -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<typeof import('react-router-dom')>();
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 ? (
<I18nProvider>
<SettingsHome />
</I18nProvider>
) : (
<SettingsHome />
);
return render(
<Provider store={makeTestStore(locale as Locale, developerMode)}>
<MemoryRouter>{content}</MemoryRouter>
</Provider>
);
}
// --- 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.
});
@@ -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');
});
});
@@ -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 `<Router>` (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 ? (
<RoutedSettingsHeader {...props} />
) : (
<SettingsHeaderView {...props} pathname="" />
);
};
const RoutedSettingsHeader = (props: SettingsHeaderProps) => {
const { pathname } = useLocation();
return <SettingsHeaderView {...props} pathname={pathname} />;
};
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/<slug>)
// 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 (
<div className={`px-5 pt-5 pb-3 ${className}`}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center min-w-0">
{/* Back button */}
{showBackButton && onBack && (
<button
onClick={onBack}
className="w-6 h-6 flex items-center justify-center rounded-full hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 transition-colors mr-2"
aria-label={t('common.back')}>
{showBack && onBack && (
<button onClick={onBack} className={backButtonClass} aria-label={t('common.back')}>
<svg
className="w-4 h-4 text-stone-500 dark:text-neutral-400"
fill="none"
@@ -57,44 +103,8 @@ const SettingsHeader = ({
</button>
)}
{/* Breadcrumbs */}
{breadcrumbs && breadcrumbs.length > 0 && (
<nav aria-label={t('common.breadcrumb')} className="mr-1">
<ol className="flex items-center gap-1">
{breadcrumbs.map((crumb, i) => (
<li key={i} className="flex items-center gap-1">
{crumb.onClick ? (
<button
onClick={crumb.onClick}
className="text-xs text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:text-neutral-300 dark:hover:text-neutral-300 transition-colors">
{crumb.label}
</button>
) : (
<span className="text-xs text-stone-400 dark:text-neutral-500">
{crumb.label}
</span>
)}
<svg
aria-hidden="true"
className="w-3 h-3 text-stone-300 dark:text-neutral-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</li>
))}
</ol>
</nav>
)}
{/* Title */}
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{title ?? t('nav.settings')}
</h2>
</div>
@@ -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(<NavigationProbe />, { 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(<NavigationProbe />, { 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(<NavigationProbe />, { 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(<NavigationProbe />, { 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(<NavigationProbe />, { 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(<NavigationProbe />, { initialEntries: ['/settings/llm'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > AI & Models');
});
test('voice returns Settings > AI & Models breadcrumb', () => {
renderWithProviders(<NavigationProbe />, { 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(<NavigationProbe />, { 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(<NavigationProbe />, { initialEntries: ['/settings/autonomy'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Agents');
});
test('agent-access returns Settings > Agents breadcrumb', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/agent-access'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Agents');
});
test('agents-settings section page returns Settings only', () => {
renderWithProviders(<NavigationProbe />, { 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(<NavigationProbe />, { initialEntries: ['/settings/tools'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Features');
});
test('companion returns Settings > Features breadcrumb', () => {
renderWithProviders(<NavigationProbe />, { 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(<NavigationProbe />, { 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(<NavigationProbe />, { initialEntries: ['/settings/task-sources'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Integrations');
});
test('webhooks-triggers returns Settings > Integrations breadcrumb', () => {
renderWithProviders(<NavigationProbe />, { 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(<NavigationProbe />, { 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(<NavigationProbe />, { 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(<NavigationProbe />, { initialEntries: ['/settings/crypto'] });
expect(screen.getByTestId('current-route')).toHaveTextContent('crypto');
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
});
test('recovery-phrase returns Settings > Crypto', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/recovery-phrase'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Crypto');
});
test('wallet-balances returns Settings > Crypto', () => {
renderWithProviders(<NavigationProbe />, { 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(<NavigationProbe />, { initialEntries: ['/settings/cron-jobs'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Developer Options');
});
test('intelligence returns Settings > Developer Options', () => {
renderWithProviders(<NavigationProbe />, { initialEntries: ['/settings/intelligence'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Developer Options');
});
test('developer-options section page returns Settings only', () => {
renderWithProviders(<NavigationProbe />, { 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(<NavigationProbe />, { initialEntries: ['/settings/messaging'] });
expect(screen.getByTestId('current-route')).toHaveTextContent('home');
});
test('completely unknown slug resolves to home', () => {
renderWithProviders(<NavigationProbe />, { 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(<NavigationProbe />, { 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');
});
});
@@ -10,53 +10,26 @@ const BreadcrumbProbe = () => {
return <div data-testid="breadcrumbs">{breadcrumbs.map(b => b.label).join(' > ')}</div>;
};
describe('useSettingsNavigation breadcrumbs', () => {
test('notification-routing returns Settings > Developer Options', () => {
renderWithProviders(<BreadcrumbProbe />, {
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(<BreadcrumbProbe />, { 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(<BreadcrumbProbe />, { initialEntries: ['/settings/notifications'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Notifications');
});
test('tasks returns Settings > Developer Options', () => {
renderWithProviders(<BreadcrumbProbe />, { initialEntries: ['/settings/tasks'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Developer Options');
});
test('developer-options returns Settings (section page)', () => {
renderWithProviders(<BreadcrumbProbe />, { initialEntries: ['/settings/developer-options'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
});
test('persona returns Settings (top-level)', () => {
renderWithProviders(<BreadcrumbProbe />, { initialEntries: ['/settings/persona'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
});
test('crypto returns Settings (section page)', () => {
renderWithProviders(<BreadcrumbProbe />, { initialEntries: ['/settings/crypto'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings');
});
test('recovery-phrase returns Settings > Crypto', () => {
renderWithProviders(<BreadcrumbProbe />, { initialEntries: ['/settings/recovery-phrase'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Crypto');
});
test('wallet-balances returns Settings > Crypto', () => {
renderWithProviders(<BreadcrumbProbe />, { initialEntries: ['/settings/wallet-balances'] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('Settings > Crypto');
test.each(routes)('breadcrumbs are empty for %s', route => {
renderWithProviders(<BreadcrumbProbe />, { initialEntries: [route] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('');
});
});
@@ -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<SettingsSection, string> = {
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 → <section label>.
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';
}
};
@@ -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 <Navigate to="/settings/account" replace />;
return null;
};
export default SettingsIndexRedirect;
@@ -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 (
<SettingsLayoutProvider value={{ inTwoPaneShell: true }}>
<TwoPanelLayout
id="settings"
// Max-width is applied once to the whole panel (sidebar + content
// together) and centered, rather than capping each settings panel.
// Card panes (bg / border / rounding) come from TwoPanelLayout's
// default paneClassName, matching the conversations two-pane.
className="mx-auto h-full w-full max-w-6xl p-4 pt-6"
defaultSidebarVisible
defaultSidebarWidth={288}
minSidebarWidth={220}
maxSidebarWidth={420}
showDividerHandle={false}
sidebar={
// overflow-hidden so the scroll lives on the sidebar's own content
// area (below the fixed search header), not this wrapper.
<div className="h-full overflow-hidden">
<SettingsSidebar />
</div>
}>
<div className="h-full overflow-y-auto">
<div className="px-4 pt-4 -mb-4">
<SettingsSubNav />
</div>
<Outlet />
</div>
</TwoPanelLayout>
</SettingsLayoutProvider>
);
};
export default SettingsLayout;
@@ -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;
@@ -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 (
<nav
aria-label={t('nav.settings')}
data-walkthrough="settings-menu"
className="flex h-full flex-col">
{/* Full-width search field as a fixed header (no padding). The scroll
lives on the content below, not on this header. */}
<SettingsSearchBar value={searchQuery} onValueChange={setSearchQuery} />
<div className="min-h-0 flex-1 overflow-y-auto px-1.5 pb-2">
{sections.map(section => (
<div
key={section.key}
data-testid={
section.labelKey ? `settings-sidebar-group-${section.key}` : 'settings-search-results'
}>
{section.labelKey && (
<div className="px-2 pb-0.5 pt-2.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{t(section.labelKey)}
</span>
</div>
)}
<ul>
{section.rows.map(row => {
const active = activeSidebarId === row.id;
const highlight = !!row.highlight;
const rowClass = active
? // Active rows highlight both background and text in the accent colour.
'bg-primary-50 font-medium text-primary-700 dark:bg-primary-500/15 dark:text-primary-200'
: highlight
? // Highlighted-but-inactive rows accent the text only (no bg).
'font-medium text-primary-700 hover:bg-stone-50 dark:text-primary-300 dark:hover:bg-neutral-800/60'
: 'text-stone-600 hover:bg-stone-50 hover:text-stone-900 dark:text-neutral-300 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-100';
return (
<li key={row.id}>
<button
type="button"
data-testid={`settings-nav-${row.id}`}
aria-current={active ? 'page' : undefined}
onClick={() => navigateToSettings(row.route)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-[13px] transition-colors ${rowClass}`}>
<span
className={`shrink-0 ${
active || highlight
? 'text-primary-600 dark:text-primary-400'
: 'text-stone-400 dark:text-neutral-500'
}`}>
{SETTINGS_NAV_ICONS[row.id] ?? null}
</span>
<span className="truncate">{row.label}</span>
</button>
</li>
);
})}
</ul>
</div>
))}
{isSearching && !hasRows && (
<p
data-testid="settings-search-empty"
className="px-2 pt-3 text-center text-xs text-stone-400 dark:text-neutral-500">
{t('settings.settingsSearch.noResults').replace('{query}', searchQuery.trim())}
</p>
)}
</div>
{/* Sticky, centered version footer. */}
<div className="shrink-0 border-t border-stone-200 py-1 text-center text-[10px] text-stone-400 dark:border-neutral-800 dark:text-neutral-500">
{t('settings.betaBuild').replace('{version}', APP_VERSION)}
</div>
</nav>
);
};
export default SettingsSidebar;
@@ -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 (
<div
className="flex flex-wrap gap-1.5 px-1 pb-3"
role="navigation"
aria-label={t('nav.settings')}
data-testid="settings-subnav">
{siblings.map(entry => {
const active = entry.id === currentRoute;
return (
<button
key={entry.id}
type="button"
data-testid={`settings-subnav-${entry.id}`}
aria-current={active ? 'page' : undefined}
onClick={() => navigateToSettings(entryRoute(entry))}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
active
? 'bg-stone-800 text-white dark:bg-neutral-100 dark:text-neutral-900'
: 'bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800'
}`}>
{t(entry.titleKey)}
</button>
);
})}
</div>
);
};
export default SettingsSubNav;
@@ -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) => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{path}
</svg>
);
const stroke = (d: string) => (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={d} />
);
export const SETTINGS_NAV_ICONS: Record<string, ReactNode> = {
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(
<Fragment>
{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')}
</Fragment>
),
// 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')),
};
@@ -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 };
@@ -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 = () => {
</Button>
</div>
</SettingsSection>
{/* Diagnostics (app logs, restart tour, staging Sentry test) —
relocated here from the retired Developer & Diagnostics page. */}
<SystemDiagnostics />
</div>
</div>
);
@@ -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 (
<div className="z-10 relative" data-testid="account-panel">
<SettingsHeader
title={t('pages.settings.accountSection.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 pt-2 space-y-5">
{(name || username) && (
<div className="flex items-center gap-3 rounded-2xl border border-stone-200 dark:border-neutral-800 px-4 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-500/15 text-sm font-semibold text-primary-700 dark:text-primary-300">
{(name ?? username ?? '?').replace('@', '').slice(0, 1).toUpperCase()}
</div>
<div className="min-w-0">
{name && (
<div className="truncate text-sm font-medium text-stone-900 dark:text-neutral-100">
{name}
</div>
)}
{username && (
<div className="truncate text-xs text-stone-500 dark:text-neutral-400">
{username}
</div>
)}
</div>
</div>
)}
<div className="rounded-2xl overflow-hidden border border-stone-200 dark:border-neutral-800">
<LogoutAndClearActions />
</div>
</div>
</div>
);
};
export default AccountPanel;
@@ -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 = () => {
)}
</SettingsSection>
{/* Action rate limit (formerly the standalone /settings/autonomy page) */}
<AutonomyRateLimitSection />
{/* Approval history */}
<SettingsSection
title={t('settings.agentAccess.approvalHistory')}
@@ -12,6 +12,7 @@ import {
type TabBarLabels,
type ThemeMode,
} from '../../../store/themeSlice';
import LanguageSelect from '../../LanguageSelect';
import SettingsHeader from '../components/SettingsHeader';
import { SettingsRow, SettingsSection, SettingsSwitch } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -311,6 +312,15 @@ const AppearancePanel = () => {
}
/>
</SettingsSection>
{/* ── Display language (moved from the old settings home list) ── */}
<SettingsSection title={t('settings.language')}>
<SettingsRow
label={t('settings.language')}
description={t('settings.languageDesc')}
control={<LanguageSelect ariaLabel={t('settings.language')} />}
/>
</SettingsSection>
</div>
</div>
);
@@ -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<number | null>(null);
const [draft, setDraft] = useState<string>('');
const [status, setStatus] = useState<Status>({ kind: 'loading' });
@@ -101,86 +99,76 @@ const AutonomyPanel = () => {
status.kind === 'error' ? `${t('autonomy.statusFailed')}: ${status.message}` : null;
return (
<div className="z-10 relative">
<SettingsHeader
title={t('autonomy.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
<SettingsSection
title={t('autonomy.maxActionsLabel')}
description={t('autonomy.maxActionsHelp')}>
<SettingsRow
stacked
control={
<div className="space-y-3">
<div className="flex items-center gap-2">
<SettingsNumberField
id="autonomy-max-actions"
value={draft}
onChange={v => {
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')}
/>
<Button
type="button"
variant="primary"
size="xs"
onClick={() => void onSave()}
disabled={!canSave}>
{status.kind === 'saving' ? t('autonomy.statusSaving') : t('common.save')}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{PRESETS.map(p => (
<Button
key={p.value}
type="button"
variant="ghost"
size="xs"
onClick={() => applyPreset(p.value)}>
{p.labelKey ? t(p.labelKey) : p.label}
</Button>
))}
</div>
{!isValid && trimmed !== '' && (
<p className="text-xs text-coral-600 dark:text-coral-300">
{t('autonomy.invalidIntegerMsg')}
</p>
)}
{isValid && parsed === UNLIMITED && (
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('autonomy.unlimitedNote')}
</p>
)}
<SettingsStatusLine
saving={status.kind === 'saving'}
savedNote={savedNote}
error={errorMsg}
savingLabel={t('autonomy.statusSaving')}
/>
</div>
}
/>
<div className="p-4 pt-2 space-y-5">
<SettingsSection
title={t('autonomy.maxActionsLabel')}
description={t('autonomy.maxActionsHelp')}>
<SettingsRow
stacked
control={
<div className="space-y-3">
<div className="flex items-center gap-2">
<SettingsNumberField
id="autonomy-max-actions"
value={draft}
onChange={v => {
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')}
/>
<Button
type="button"
variant="primary"
size="xs"
onClick={() => void onSave()}
disabled={!canSave}>
{status.kind === 'saving' ? t('autonomy.statusSaving') : t('common.save')}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{PRESETS.map(p => (
<Button
key={p.value}
type="button"
variant="ghost"
size="xs"
onClick={() => applyPreset(p.value)}>
{p.labelKey ? t(p.labelKey) : p.label}
</Button>
))}
</div>
{!isValid && trimmed !== '' && (
<p className="text-xs text-coral-600 dark:text-coral-300">
{t('autonomy.invalidIntegerMsg')}
</p>
)}
{isValid && parsed === UNLIMITED && (
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('autonomy.unlimitedNote')}
</p>
)}
<SettingsStatusLine
saving={status.kind === 'saving'}
savedNote={savedNote}
error={errorMsg}
savingLabel={t('autonomy.statusSaving')}
/>
</div>
}
/>
</SettingsSection>
</div>
</div>
</SettingsSection>
);
};
export default AutonomyPanel;
export default AutonomyRateLimitSection;
@@ -27,53 +27,34 @@ describe('<BillingPanel />', () => {
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(<Panel />);
// 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(<Panel />);
// 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(<Panel />);
// 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(<Panel />);
await waitFor(() => expect(openUrlMock).toHaveBeenCalledTimes(1));
// The SettingsHeader back button (aria-label "Back") and the inline
// "Back to settings" button both route through navigateBack.
@@ -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 (
<div className="z-10 relative">
@@ -77,22 +46,6 @@ const BillingPanel = () => {
{t('settings.billing.backToSettings')}
</Button>
</div>
{status === 'opening' && (
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.billing.openingBrowser')}
</p>
)}
{status === 'idle' && (
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.billing.browserNotOpen')}
</p>
)}
{status === 'error' && (
<p className="text-xs text-coral-600 dark:text-coral-300">
{t('settings.billing.browserOpenFailed')}
</p>
)}
</div>
</div>
</div>
@@ -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 (
<div className="z-10 relative">
<div className="px-5 pt-5 pb-3">
<SettingsHeader
title={t('settings.devices')}
showBackButton={breadcrumbs.length > 0}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
</div>
<div className="px-5 pb-5">
<EmptyStateCard
className="shadow-soft"
icon={
<svg
className="h-7 w-7 text-primary-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
}
title="Devices"
description="Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices."
footer={
<span className="mt-4 inline-flex items-center rounded-full bg-primary-50 dark:bg-primary-500/10 px-3 py-1 text-xs font-medium text-primary-600 dark:text-primary-400">
Coming Soon
</span>
}
/>
</div>
</div>
);
};
export default DevicesComingSoonPanel;
@@ -284,7 +284,7 @@ const DevicesPanel = () => {
<div className="z-10 relative">
<SettingsHeader
title={t('devices.title')}
showBackButton={breadcrumbs.length > 0}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
action={
@@ -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<AISettings | null>(null);
const [loadError, setLoadError] = useState<string | null>(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 (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.heartbeat.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-3">
<SettingsStatusLine saving={false} error={loadError} savingLabel="" />
{snapshot ? (
<BackgroundLoopControls
view="heartbeat"
hideHeader
routing={snapshot.routing}
cloudProviders={snapshot.cloudProviders}
/>
) : !loadError ? (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('common.loading')}
</div>
) : null}
</div>
</div>
);
};
export default HeartbeatPanel;
@@ -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<TabId, string> = {
'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 (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.integrations.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div
role="tablist"
aria-label={t('settings.integrations.title')}
className="flex gap-1 px-4 pt-3 border-b border-neutral-200 dark:border-neutral-800">
{tabs.map(({ id, label }) => {
const selected = tab === id;
return (
<button
key={id}
type="button"
role="tab"
aria-selected={selected}
data-testid={`integrations-tab-${id}`}
onClick={() => selectTab(id)}
className={`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
selected
? 'border-primary-500 text-neutral-800 dark:text-neutral-100'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200'
}`}>
{label}
</button>
);
})}
</div>
{tab === 'task-sources' && <TaskSourcesPanel embedded />}
{tab === 'composio' && (
<div className="p-4">
<ComposioPanel embedded />
</div>
)}
{tab === 'webhooks' && <Webhooks embedded />}
</div>
);
};
export default IntegrationsPanel;
@@ -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<AISettings | null>(null);
const [loadError, setLoadError] = useState<string | null>(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 (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.ledgerUsage.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-3">
<SettingsStatusLine saving={false} error={loadError} savingLabel="" />
{snapshot ? (
<BackgroundLoopControls
view="ledger"
hideHeader
routing={snapshot.routing}
cloudProviders={snapshot.cloudProviders}
/>
) : !loadError ? (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('common.loading')}
</div>
) : null}
</div>
</div>
);
};
export default LedgerUsagePanel;
@@ -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 (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.mascot.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
{!embedded && (
<SettingsHeader
title={t('settings.mascot.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<div className="p-4 space-y-4">
{/* ── Mascot preview (intentional bespoke visual) ───────────── */}
@@ -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(<PersonaPanel />);
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');
});
});
@@ -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 (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.persona.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
{!embedded && (
<SettingsHeader
title={t('settings.persona.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<div className="p-4 pt-2 space-y-5">
{/* ── Identity ─────────────────────────────────────────────── */}
@@ -257,7 +265,7 @@ const PersonaPanel = () => {
<button
type="button"
data-testid="persona-open-mascot"
onClick={() => navigateToSettings('mascot')}
onClick={() => navigateToSettings('personality#face')}
className="flex w-full items-center justify-between text-left text-sm text-neutral-800 dark:text-neutral-200 hover:text-primary-700 dark:hover:text-primary-300">
<span>{t('settings.persona.openMascotSettings')}</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -0,0 +1,78 @@
import { useLocation, useNavigate } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import MascotPanel from './MascotPanel';
import PersonaPanel from './PersonaPanel';
type TabId = 'personality' | 'face';
const TAB_HASH: Record<TabId, string> = { personality: '', face: '#face' };
const hashToTab = (hash: string): TabId => (hash === '#face' ? 'face' : 'personality');
/**
* Single Settings entry for the assistant's character. Combines the persona
* editor (PersonaPanel) and the face/mascot picker (MascotPanel, previously
* the separate /settings/mascot page) as two tabs under one header. The
* active tab is reflected in the URL hash (`#face`) so deep links and the
* legacy persona/mascot redirects land on the right view.
*/
const PersonalityPanel = () => {
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: 'personality', label: t('settings.assistant.personality') },
{ id: 'face', label: t('settings.assistant.faceMascot') },
];
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.personalityFace.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div
role="tablist"
aria-label={t('settings.personalityFace.title')}
className="flex gap-1 px-4 pt-3 border-b border-neutral-200 dark:border-neutral-800">
{tabs.map(({ id, label }) => {
const selected = tab === id;
return (
<button
key={id}
type="button"
role="tab"
aria-selected={selected}
data-testid={`personality-tab-${id}`}
onClick={() => selectTab(id)}
className={`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
selected
? 'border-primary-500 text-neutral-800 dark:text-neutral-100'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200'
}`}>
{label}
</button>
);
})}
</div>
{tab === 'personality' ? <PersonaPanel embedded /> : <MascotPanel embedded />}
</div>
);
};
export default PersonalityPanel;
@@ -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<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isTauri()) return;
invoke<string | null>('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 (
<div className="rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-neutral-800 dark:bg-neutral-800/60">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{t('devOptions.appLogs')}
</div>
<div className="mt-0.5 text-xs text-neutral-700 dark:text-neutral-300">
{t('devOptions.appLogsDesc')}
</div>
{path && (
<div className="mt-1 truncate font-mono text-[11px] text-neutral-500 dark:text-neutral-400">
{path}
</div>
)}
</div>
<button
onClick={onClick}
className="shrink-0 rounded-md bg-neutral-700 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-neutral-600">
{t('devOptions.openLogsFolder')}
</button>
</div>
{error && (
<div
role="status"
aria-live="polite"
className="mt-2 text-xs text-coral-600 dark:text-coral-300">
{error}
</div>
)}
</div>
);
};
type SentryTestStatus =
| { kind: 'idle' }
| { kind: 'sending' }
| { kind: 'sent'; eventId: string | undefined }
| { kind: 'error'; message: string };
const SentryTestRow = () => {
const { t } = useT();
const [status, setStatus] = useState<SentryTestStatus>({ 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 (
<div className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 dark:border-amber-500/40 dark:bg-amber-500/10">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-amber-900 dark:text-amber-300">
{t('devOptions.triggerSentryTest')}
</div>
<div className="mt-0.5 text-xs text-amber-800 dark:text-amber-200">
{t('devOptions.triggerSentryTestDesc')}
</div>
</div>
<button
onClick={onClick}
disabled={status.kind === 'sending'}
className="shrink-0 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-amber-500 disabled:opacity-60">
{status.kind === 'sending' ? t('devOptions.sending') : t('devOptions.sendTestEvent')}
</button>
</div>
<div role="status" aria-live="polite" aria-atomic="true" className="mt-2 text-xs">
{status.kind === 'sent' && (
<span className="text-amber-900 dark:text-amber-300">
{t('devOptions.eventSent')}.{' '}
{status.eventId ? (
<span className="font-mono">id: {status.eventId}</span>
) : (
<span>{t('devOptions.sentryDisabled')}</span>
)}
</span>
)}
{status.kind === 'error' && (
<span className="text-coral-600 dark:text-coral-300">
{t('devOptions.failed')}: {status.message}
</span>
)}
</div>
</div>
);
};
const RestartTourRow = () => {
const { t } = useT();
const navigate = useNavigate();
const onClick = () => {
resetWalkthrough();
navigate('/home');
};
return (
<div className="rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-neutral-800 dark:bg-neutral-800/60">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{t('settings.restartTour')}
</div>
<div className="mt-0.5 text-xs text-neutral-700 dark:text-neutral-300">
{t('settings.restartTourDesc')}
</div>
</div>
<button
onClick={onClick}
className="shrink-0 rounded-md bg-neutral-700 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-neutral-600">
{t('settings.restartTour')}
</button>
</div>
</div>
);
};
/** App logs, optional staging Sentry test, and the restart-tour action. */
const SystemDiagnostics = () => {
const { t } = useT();
const showSentryTest = APP_ENVIRONMENT === 'staging';
return (
<div>
<h3 className="px-1 pb-2 text-sm font-medium text-neutral-800 dark:text-neutral-100">
{t('devOptions.titleDiagnostics')}
</h3>
<div className="flex flex-col gap-3">
<LogsFolderRow />
{showSentryTest && <SentryTestRow />}
<RestartTourRow />
</div>
</div>
);
};
export default SystemDiagnostics;
@@ -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 (
<div className="z-10 relative" data-testid="task-sources-panel">
<SettingsHeader
title={t('settings.taskSources.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
{!embedded && (
<SettingsHeader
title={t('settings.taskSources.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<div className="p-4 pt-2 space-y-5">
<div className="space-y-1">
@@ -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<TabId, string> = { 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 (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.usage.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div
role="tablist"
aria-label={t('settings.usage.title')}
className="flex gap-1 px-4 pt-3 border-b border-neutral-200 dark:border-neutral-800">
{tabs.map(({ id, label }) => {
const selected = tab === id;
return (
<button
key={id}
type="button"
role="tab"
aria-selected={selected}
data-testid={`usage-tab-${id}`}
onClick={() => selectTab(id)}
className={`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
selected
? 'border-primary-500 text-neutral-800 dark:text-neutral-100'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200'
}`}>
{label}
</button>
);
})}
</div>
{tab === 'costs' ? <CostDashboardPanel embedded /> : <BackgroundActivityTab />}
</div>
);
};
/**
* 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<AISettings | null>(null);
const [loadError, setLoadError] = useState<string | null>(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 (
<div className="p-4 space-y-3" data-testid="usage-background-tab">
<SettingsStatusLine saving={false} error={loadError} savingLabel="" />
{snapshot ? (
<BackgroundLoopControls
view="all"
hideHeader
routing={snapshot.routing}
cloudProviders={snapshot.cloudProviders}
/>
) : !loadError ? (
<div className="text-xs text-neutral-500 dark:text-neutral-400">{t('common.loading')}</div>
) : null}
</div>
);
};
export default UsagePanel;
@@ -1283,7 +1283,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
{t('voice.providers.mascotVoiceDescPrefix')}{' '}
<button
type="button"
onClick={() => navigateToSettings('mascot')}
onClick={() => navigateToSettings('personality#face')}
className="underline text-primary-600 dark:text-primary-300 hover:text-primary-700 dark:hover:text-primary-200">
{t('voice.providers.mascotSettings')}
</button>
@@ -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<typeof import('../../../../utils/tauriCommands/config')>(
'../../../../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(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
renderWithProviders(<AutonomyRateLimitSection />, {
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(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
renderWithProviders(<AutonomyRateLimitSection />, {
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(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
renderWithProviders(<AutonomyRateLimitSection />, {
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(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
renderWithProviders(<AutonomyRateLimitSection />, {
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(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
renderWithProviders(<AutonomyRateLimitSection />, {
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(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
renderWithProviders(<AutonomyRateLimitSection />, {
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(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
renderWithProviders(<AutonomyRateLimitSection />, {
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(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
renderWithProviders(<AutonomyRateLimitSection />, {
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(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
renderWithProviders(<AutonomyRateLimitSection />, {
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(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
renderWithProviders(<AutonomyRateLimitSection />, {
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(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
renderWithProviders(<AutonomyRateLimitSection />, {
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(<AutonomyPanel />, { initialEntries: ['/settings/autonomy'] });
renderWithProviders(<AutonomyRateLimitSection />, {
initialEntries: ['/settings/agent-access'],
});
const input = await screen.findByDisplayValue('50');
fireEvent.change(input, { target: { value: '500' } });
@@ -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 }) => (
<div data-testid="stub-task-sources" data-embedded={String(embedded ?? false)} />
),
}));
vi.mock('../ComposioPanel', () => ({
default: ({ embedded }: { embedded?: boolean }) => (
<div data-testid="stub-composio" data-embedded={String(embedded ?? false)} />
),
}));
vi.mock('../../../../pages/Webhooks', () => ({
default: ({ embedded }: { embedded?: boolean }) => (
<div data-testid="stub-webhooks" data-embedded={String(embedded ?? false)} />
),
}));
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({
navigateBack: vi.fn(),
navigateToSettings: vi.fn(),
breadcrumbs: [],
}),
}));
describe('IntegrationsPanel', () => {
test('default hash renders the Task sources tab embedded', () => {
renderWithProviders(<IntegrationsPanel />, { 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(<IntegrationsPanel />, {
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(<IntegrationsPanel />, {
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(<IntegrationsPanel />, { 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');
});
});
@@ -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 }) => (
<div data-testid="stub-persona" data-embedded={String(embedded ?? false)} />
),
}));
vi.mock('../MascotPanel', () => ({
default: ({ embedded }: { embedded?: boolean }) => (
<div data-testid="stub-mascot" data-embedded={String(embedded ?? false)} />
),
}));
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(<PersonalityPanel />, { 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(<PersonalityPanel />, { 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(<PersonalityPanel />, { 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(<PersonalityPanel />, { initialEntries: ['/settings/personality#face'] });
fireEvent.click(screen.getByTestId('personality-tab-personality'));
await screen.findByTestId('stub-persona');
expect(screen.queryByTestId('stub-mascot')).not.toBeInTheDocument();
});
});
@@ -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 }) => (
<div data-testid="stub-cost-dashboard" data-embedded={String(embedded ?? false)} />
),
}));
vi.mock('../AIPanel', () => ({
BackgroundLoopControls: ({ view, hideHeader }: { view?: string; hideHeader?: boolean }) => (
<div
data-testid="stub-background-loops"
data-view={view}
data-hide-header={String(hideHeader ?? false)}
/>
),
}));
vi.mock('../../../../services/api/aiSettingsApi', async () => {
const actual = await vi.importActual<typeof import('../../../../services/api/aiSettingsApi')>(
'../../../../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<typeof loadAISettings>
>;
describe('UsagePanel', () => {
beforeEach(() => {
mockLoad.mockReset();
mockLoad.mockResolvedValue(snapshot);
});
test('default hash renders the Costs tab with the embedded cost dashboard', () => {
renderWithProviders(<UsagePanel />, { 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(<UsagePanel />, { 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(<UsagePanel />, { 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(<UsagePanel />, { 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(<UsagePanel />, { initialEntries: ['/settings/usage#background'] });
await waitFor(() =>
expect(screen.getByTestId('usage-background-tab')).toHaveTextContent(/rpc down/)
);
expect(screen.queryByTestId('stub-background-loops')).not.toBeInTheDocument();
});
});
@@ -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 <SettingsSearchBar value={value} onValueChange={setValue} />;
@@ -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(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness />);
// '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(<Harness />);
type('cron');
expect(screen.getByTestId('settings-search-result-cron-jobs')).toBeTruthy();
});
test('Enter activates the highlighted result', () => {
render(<Harness />);
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', () => {
@@ -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<HTMLInputElement | null>(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<HTMLInputElement>) => {
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 (
<div className="px-4 pt-4" data-testid="settings-search">
<div className="relative">
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-stone-400 dark:text-neutral-500">
<SearchIcon />
</span>
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded={isSearching}
aria-controls={listboxId}
aria-activedescendant={isSearching && results.length ? optionId(activeIndex) : undefined}
aria-label={t('settings.settingsSearch.ariaLabel')}
autoComplete="off"
spellCheck={false}
value={value}
onChange={event => 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 && (
<button
type="button"
onClick={() => {
onValueChange('');
inputRef.current?.focus();
}}
aria-label={t('settings.settingsSearch.clear')}
data-testid="settings-search-clear"
className="absolute inset-y-0 right-2 flex items-center px-1.5 text-stone-400 hover:text-stone-600 dark:text-neutral-500 dark:hover:text-neutral-300">
<ClearIcon />
</button>
)}
</div>
{isSearching && (
<div className="pt-3 pb-5">
{results.length === 0 ? (
<div
role="status"
data-testid="settings-search-empty"
className="rounded-3xl border border-stone-200 bg-stone-50 px-4 py-6 text-center text-sm text-stone-500 dark:border-neutral-800 dark:bg-neutral-900/40 dark:text-neutral-400">
{t('settings.settingsSearch.noResults').replace('{query}', value.trim())}
</div>
) : (
<ul
id={listboxId}
role="listbox"
aria-label={t('settings.settingsSearch.resultsLabel')}
data-testid="settings-search-results"
className="overflow-hidden rounded-3xl border border-stone-200 dark:border-neutral-800">
{results.map((result, index) => (
<li
key={result.entry.id}
id={optionId(index)}
role="option"
aria-selected={index === activeIndex}
data-testid={`settings-search-result-${result.entry.id}`}
onMouseEnter={() => 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'
}`}>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-stone-900 dark:text-neutral-100">
{result.title}
</div>
{result.description && (
<div className="truncate text-xs text-stone-500 dark:text-neutral-400">
{result.description}
</div>
)}
</div>
<span className="shrink-0 rounded-full bg-stone-100 px-2 py-0.5 text-[11px] font-medium text-stone-500 dark:bg-neutral-800 dark:text-neutral-400">
{result.section}
</span>
</li>
))}
</ul>
)}
</div>
<div data-testid="settings-search" className="relative shrink-0">
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-stone-400 dark:text-neutral-500">
<SearchIcon />
</span>
<input
ref={inputRef}
type="text"
aria-label={t('settings.settingsSearch.ariaLabel')}
autoComplete="off"
spellCheck={false}
value={value}
onChange={event => 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 && (
<button
type="button"
onClick={() => {
onValueChange('');
inputRef.current?.focus();
}}
aria-label={t('settings.settingsSearch.clear')}
data-testid="settings-search-clear"
className="absolute inset-y-0 right-2 flex items-center px-1.5 text-stone-400 hover:text-stone-600 dark:text-neutral-500 dark:hover:text-neutral-300">
<ClearIcon />
</button>
)}
</div>
);
@@ -48,7 +48,6 @@ const SECTION_KEY: Record<SettingsSection, string> = {
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<SettingsSection, string> = {
// 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<string, string> = {
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<string, string> = {
ai: 'nav.settings',
'agents-settings': 'nav.settings',
features: 'nav.settings',
composio: 'nav.settings',
integrations: 'nav.settings',
'developer-options': 'settings.developerDiagnostics',
};
@@ -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<SettingsNavGroup, string> = {
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 itselfits 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);
+27
View File
@@ -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;
+19 -11
View File
@@ -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، وأثرها، وقم بتوجيهها إلى لوحة مهام الوكيل.',
+19 -9
View File
@@ -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 থেকে কাজের আইটেম সংগ্রহ করুন, সেগুলো সমৃদ্ধ করুন, এবং এজেন্টের টোডো বোর্ডে রুট করুন।',
+20 -12
View File
@@ -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.',
+19 -10
View File
@@ -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.',
+20 -12
View File
@@ -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':
+21 -12
View File
@@ -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 linstant — 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':
+19 -11
View File
@@ -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, नॉटियन, रैखिक और क्लिकअप से कार्य वस्तुओं को इकट्ठा करें, उन्हें समृद्ध करें और उन्हें एजेंट टोडो बोर्ड पर ले जाएं।',
+19 -11
View File
@@ -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.',
+20 -12
View File
@@ -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':
+19 -10
View File
@@ -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에서 작업 항목을 수집하고 보강한 뒤 에이전트 할 일 보드로 라우팅합니다.',
+19 -11
View File
@@ -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.',
+19 -12
View File
@@ -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':
+19 -11
View File
@@ -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, обогащайте их и направляйте на доску задач агента.',
+19 -9
View File
@@ -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 收集工作项,补充信息后路由到智能体待办板。',
+233 -69
View File
@@ -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<BrainTab> = new Set<BrainTab>([
'intelligence',
'memory-data',
'memory-debug',
'analysis-views',
]);
/** Small inline icon helper for the Brain sidebar nav. */
const navIcon = (d: string) => (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={d} />
</svg>
);
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<BrainTab>('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<BrainTab>(() => {
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<GraphExportResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<GraphMode>('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 (
<div className="relative min-h-full p-4 pt-6">
<div className="mx-auto max-w-4xl space-y-5">
<header className="min-w-0">
<h1 className="text-xl font-bold text-stone-900 dark:text-neutral-100">
{t('nav.brain')}
</h1>
<p className="mt-1 text-sm text-stone-500 dark:text-neutral-400">{t('brain.subtitle')}</p>
</header>
<PillTabBar<BrainTab>
selected={activeTab}
onChange={setActiveTab}
items={[
{ value: 'memory', label: t('brain.tabs.memory') },
{ value: 'subconscious', label: t('brain.tabs.subconscious') },
]}
/>
{activeTab === 'memory' && (
<div className="space-y-5 animate-fade-up">
<MemoryControls
mode={mode}
onModeChange={setMode}
onRefresh={refresh}
onToast={addToast}
contentRootAbs={graph?.content_root_abs}
/>
{graph ? (
<MemoryGraph
nodes={graph.nodes}
edges={graph.edges}
mode={mode}
emptyHint={t('brain.empty')}
/>
) : error ? (
<div
className={`${cardClass} text-sm text-coral-600 dark:text-coral-400`}
role="alert">
{t('brain.error')}
<div className="h-full">
<TwoPanelLayout
id="brain"
// Max-width applied once to the whole panel (sidebar + content) and
// centered, matching the settings two-pane shell.
className="mx-auto h-full w-full max-w-6xl p-4 pt-6"
defaultSidebarVisible
defaultSidebarWidth={210}
minSidebarWidth={170}
maxSidebarWidth={320}
showDividerHandle={false}
sidebar={
<TwoPaneNav
ariaLabel={t('nav.brain')}
selected={activeTab}
onSelect={value => 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={
<div className="min-w-0">
<h1 className="text-base font-bold text-stone-900 dark:text-neutral-100">
{t('nav.brain')}
</h1>
<p className="mt-0.5 text-[11px] text-stone-500 dark:text-neutral-400">
{t('brain.subtitle')}
</p>
</div>
) : null}
}
/>
}>
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-3xl space-y-5 px-2">
{activeTab === 'graph' && (
<div className="space-y-5 animate-fade-up">
<MemoryControls
mode={mode}
onModeChange={setMode}
onRefresh={refresh}
onToast={addToast}
contentRootAbs={graph?.content_root_abs}
/>
<div className="space-y-5">
<div className={cardClass}>
<MemoryTreeStatusPanel onToast={addToast} />
{graph ? (
<MemoryGraph
nodes={graph.nodes}
edges={graph.edges}
mode={mode}
emptyHint={t('brain.empty')}
/>
) : error ? (
<div
className={`${cardClass} text-sm text-coral-600 dark:text-coral-400`}
role="alert">
{t('brain.error')}
</div>
) : null}
</div>
<MemorySourcesRegistry onToast={addToast} />
</div>
</div>
)}
)}
{activeTab === 'subconscious' && (
<div className="space-y-3 animate-fade-up">
<BetaBanner />
<div className={cardClass}>
<IntelligenceSubconsciousTab
status={sub.status}
mode={sub.mode}
intervalMinutes={sub.intervalMinutes}
triggerTick={sub.triggerTick}
triggering={sub.triggering}
settingMode={sub.settingMode}
setMode={sub.setMode}
setIntervalMinutes={sub.setIntervalMinutes}
/>
</div>
{activeTab === 'sources' && (
<div className="space-y-5 animate-fade-up">
<MemorySourcesRegistry onToast={addToast} />
</div>
)}
{activeTab === 'sync' && (
<div className="space-y-5 animate-fade-up">
<div className={cardClass}>
<MemoryTreeStatusPanel onToast={addToast} />
</div>
</div>
)}
{/* 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) && (
<div className="animate-fade-up">
<SettingsLayoutProvider value={{ inTwoPaneShell: true }}>
{/* 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' && <Intelligence tabParamKey="itab" />}
{activeTab === 'memory-data' && <MemoryDataPanel />}
{activeTab === 'memory-debug' && <MemoryDebugPanel />}
{activeTab === 'analysis-views' && <AnalysisViewsPanel />}
</SettingsLayoutProvider>
</div>
)}
{activeTab === 'subconscious' && (
<div className="space-y-3 animate-fade-up">
<BetaBanner />
<div className={cardClass}>
<IntelligenceSubconsciousTab
status={sub.status}
mode={sub.mode}
intervalMinutes={sub.intervalMinutes}
triggerTick={sub.triggerTick}
triggering={sub.triggering}
settingMode={sub.settingMode}
setMode={sub.setMode}
setIntervalMinutes={sub.setIntervalMinutes}
/>
</div>
</div>
)}
</div>
)}
</div>
</div>
</TwoPanelLayout>
<ToastContainer notifications={toasts} onRemove={removeToast} />
</div>
File diff suppressed because it is too large Load Diff
+15 -5
View File
@@ -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 (`?<tabParamKey>=…`) 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
+172 -603
View File
@@ -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 = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
);
const TeamIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
);
const PrivacyIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
);
const SecurityIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/>
</svg>
);
const MigrationIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"
/>
</svg>
);
const ScreenIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 5h18v12H3zM8 21h8m-4-4v4"
/>
</svg>
);
const NotificationsIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="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"
/>
</svg>
);
const ToolsIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="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.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
const NotificationSettingsIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
/>
</svg>
);
const LlmIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="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"
/>
</svg>
);
const CompanionIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="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"
/>
</svg>
);
const VoiceIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="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"
/>
</svg>
);
const AgentAccessIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
);
const WalletIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
);
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 (
<div className="p-4 pt-6">
<div
className={`${maxWidthClass} mx-auto bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800 overflow-hidden`}>
{children}
</div>
</div>
);
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 <div className="p-4 pt-4">{children}</div>;
};
/**
* 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 }) => (
<WrappedSettingsPage maxWidthClass={opts?.maxWidthClass}>
{element}
<div className="border-t border-stone-100 dark:border-neutral-800 px-4 py-3 text-center text-[11px] text-stone-400 dark:text-neutral-500">
{t('settings.betaBuild').replace('{version}', APP_VERSION)}
</div>
</WrappedSettingsPage>
const wrapSettingsPage = (element: ReactNode) => (
<WrappedSettingsPage>{element}</WrappedSettingsPage>
);
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 (
<div>
// 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.
<div className="h-full">
<Routes>
<Route index element={wrapSettingsPage(<SettingsHome />)} />
<Route
path="account"
element={wrapSettingsPage(
<SettingsSectionPage
title={t('pages.settings.accountSection.title')}
description={t('pages.settings.accountSection.description')}
items={accountSettingsItems}
footer={<LogoutAndClearActions />}
/>
)}
/>
<Route
path="features"
element={wrapSettingsPage(
<SettingsSectionPage
title={t('pages.settings.featuresSection.title')}
description={t('pages.settings.featuresSection.description')}
items={featuresSettingsItems}
/>
)}
/>
<Route
path="ai"
element={wrapSettingsPage(
<SettingsSectionPage
title={t('pages.settings.aiSection.title')}
description={t('pages.settings.aiSection.description')}
items={aiSettingsItems}
/>
)}
/>
<Route
path="composio"
element={wrapSettingsPage(
<SettingsSectionPage
title={t('pages.settings.composioSection.title')}
description={t('pages.settings.composioSection.description')}
items={composioSettingsItems}
/>
)}
/>
<Route
path="agents-settings"
element={wrapSettingsPage(
<SettingsSectionPage
title={t('settings.agentsSection.title')}
description={t('settings.agentsSection.description')}
items={agentsSettingsItems}
/>
)}
/>
<Route
path="crypto"
element={wrapSettingsPage(
<SettingsSectionPage
title={t('settings.cryptoSection.title')}
description={t('settings.cryptoSection.description')}
items={cryptoSettingsItems}
/>
)}
/>
<Route
path="notifications-hub"
element={wrapSettingsPage(
<SettingsSectionPage
title={t('settings.notificationsHub.title')}
description={t('settings.notificationsHub.description')}
items={notificationsHubItems}
/>
)}
/>
{/* Account & Billing leaf panels */}
<Route path="recovery-phrase" element={wrapSettingsPage(<RecoveryPhrasePanel />)} />
<Route path="team" element={wrapSettingsPage(<TeamPanel />)} />
<Route path="team/manage/:teamId" element={wrapSettingsPage(<TeamManagementPanel />)} />
<Route
path="team/manage/:teamId/members"
element={wrapSettingsPage(<TeamMembersPanel />)}
/>
<Route
path="team/manage/:teamId/invites"
element={wrapSettingsPage(<TeamInvitesPanel />)}
/>
<Route path="team/members" element={wrapSettingsPage(<TeamMembersPanel />)} />
<Route path="team/invites" element={wrapSettingsPage(<TeamInvitesPanel />)} />
<Route path="billing" element={wrapSettingsPage(<BillingPanel />)} />
<Route path="privacy" element={wrapSettingsPage(<PrivacyPanel />)} />
<Route path="security" element={wrapSettingsPage(<SecurityPanel />)} />
<Route path="migration" element={wrapSettingsPage(<MigrationPanel />)} />
<Route path="wallet-balances" element={wrapSettingsPage(<WalletBalancesPanel />)} />
{/* Features leaf panels */}
<Route path="screen-intelligence" element={wrapSettingsPage(<ScreenIntelligencePanel />)} />
<Route path="autocomplete" element={wrapSettingsPage(<AutocompletePanel />)} />
<Route path="voice" element={wrapSettingsPage(<VoicePanel />)} />
<Route path="notifications" element={wrapSettingsPage(<NotificationsTabbedPanel />)} />
<Route path="mascot" element={wrapSettingsPage(<MascotPanel />)} />
<Route path="persona" element={wrapSettingsPage(<PersonaPanel />)} />
<Route path="appearance" element={wrapSettingsPage(<AppearancePanel />)} />
<Route path="agent-access" element={wrapSettingsPage(<AgentAccessPanel />)} />
<Route path="permissions" element={wrapSettingsPage(<PermissionsPanel />)} />
<Route path="activity-level" element={wrapSettingsPage(<AgentActivityPanel />)} />
<Route path="sandbox-settings" element={wrapSettingsPage(<SandboxSettingsPanel />)} />
<Route path="approval-history" element={wrapSettingsPage(<ApprovalHistoryPanel />)} />
<Route path="agents" element={wrapSettingsPage(<AgentsPanel />)} />
<Route path="agents/new" element={wrapSettingsPage(<AgentEditorPage />)} />
<Route path="agents/edit/:id" element={wrapSettingsPage(<AgentEditorPage />)} />
<Route path="profiles" element={wrapSettingsPage(<ProfilesPanel />)} />
<Route path="profiles/new" element={wrapSettingsPage(<ProfileEditorPage />)} />
<Route path="profiles/edit/:id" element={wrapSettingsPage(<ProfileEditorPage />)} />
<Route path="tools" element={wrapSettingsPage(<ToolsPanel />)} />
<Route path="companion" element={wrapSettingsPage(<CompanionPanel />)} />
{/* Developer Options */}
<Route path="developer-options" element={wrapSettingsPage(<DeveloperOptionsPanel />)} />
<Route
path="tool-policy-diagnostics"
element={wrapSettingsPage(<ToolPolicyDiagnosticsPanel />)}
/>
<Route path="autonomy" element={wrapSettingsPage(<AutonomyPanel />)} />
<Route path="mcp-server" element={wrapSettingsPage(<McpServerPanel />)} />
{/* 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. */}
<Route
path="notification-routing"
element={<Navigate to="/settings/notifications#routing" replace />}
/>
<Route path="llm" element={wrapSettingsPage(<AIPanel />, { maxWidthClass: 'max-w-4xl' })} />
<Route path="embeddings" element={wrapSettingsPage(<EmbeddingsPanel />)} />
<Route
path="heartbeat"
element={wrapSettingsPage(<HeartbeatPanel />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route
path="ledger-usage"
element={wrapSettingsPage(<LedgerUsagePanel />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route
path="cost-dashboard"
element={wrapSettingsPage(<CostDashboardPanel />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route path="search" element={wrapSettingsPage(<SearchPanel />)} />
<Route path="agent-chat" element={wrapSettingsPage(<AgentChatPanel />)} />
<Route path="cron-jobs" element={wrapSettingsPage(<CronJobsPanel />)} />
<Route path="task-sources" element={wrapSettingsPage(<TaskSourcesPanel />)} />
<Route path="tasks" element={wrapSettingsPage(<TasksPanel />)} />
<Route
path="automations"
element={wrapSettingsPage(<WorkflowsTab />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route path="dev-workflow" element={wrapSettingsPage(<DevWorkflowPanel />)} />
<Route path="skills-runner" element={wrapSettingsPage(<WorkflowRunnerPanel />)} />
<Route
path="screen-awareness-debug"
element={wrapSettingsPage(<ScreenAwarenessDebugPanel />)}
/>
<Route path="autocomplete-debug" element={wrapSettingsPage(<AutocompleteDebugPanel />)} />
<Route path="voice-debug" element={wrapSettingsPage(<VoiceDebugPanel />)} />
<Route path="local-model-debug" element={wrapSettingsPage(<LocalModelDebugPanel />)} />
<Route path="webhooks-debug" element={wrapSettingsPage(<WebhooksDebugPanel />)} />
<Route path="event-log" element={wrapSettingsPage(<EventLogPanel />)} />
<Route
path="model-health"
element={wrapSettingsPage(<ModelHealthPanel />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route
path="memory-sync"
element={wrapSettingsPage(<MemorySyncPanel />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route path="memory-data" element={wrapSettingsPage(<MemoryDataPanel />)} />
<Route path="memory-debug" element={wrapSettingsPage(<MemoryDebugPanel />)} />
<Route
path="analysis-views"
element={wrapSettingsPage(<AnalysisViewsPanel />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route
path="intelligence"
element={wrapSettingsPage(<Intelligence />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route
path="webhooks-triggers"
element={wrapSettingsPage(<Webhooks />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route path="composio-triggers" element={wrapSettingsPage(<ComposioTriagePanel />)} />
<Route path="composio-routing" element={wrapSettingsPage(<ComposioPanel />)} />
{/* Mobile devices */}
<Route path="devices" element={wrapSettingsPage(<DevicesComingSoonPanel />)} />
{/* About / updates */}
<Route path="about" element={wrapSettingsPage(<AboutPanel />)} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/settings" replace />} />
<Route element={<SettingsLayout />}>
<Route index element={<SettingsIndexRedirect />} />
{/* ── General ─────────────────────────────────────────────── */}
<Route path="account" element={wrapSettingsPage(<AccountPanel />)} />
<Route path="team" element={wrapSettingsPage(<TeamPanel />)} />
<Route path="team/manage/:teamId" element={wrapSettingsPage(<TeamManagementPanel />)} />
<Route
path="team/manage/:teamId/members"
element={wrapSettingsPage(<TeamMembersPanel />)}
/>
<Route
path="team/manage/:teamId/invites"
element={wrapSettingsPage(<TeamInvitesPanel />)}
/>
<Route path="team/members" element={wrapSettingsPage(<TeamMembersPanel />)} />
<Route path="team/invites" element={wrapSettingsPage(<TeamInvitesPanel />)} />
<Route path="billing" element={wrapSettingsPage(<BillingPanel />)} />
<Route path="privacy" element={wrapSettingsPage(<PrivacyPanel />)} />
<Route path="security" element={wrapSettingsPage(<SecurityPanel />)} />
<Route path="migration" element={wrapSettingsPage(<MigrationPanel />)} />
<Route path="appearance" element={wrapSettingsPage(<AppearancePanel />)} />
<Route path="notifications" element={wrapSettingsPage(<NotificationsTabbedPanel />)} />
{/* Real device-pairing panel (replaces the old "Coming Soon" stub). */}
<Route path="devices" element={wrapSettingsPage(<DevicesPanel />)} />
{/* ── Assistant ───────────────────────────────────────────── */}
{/* LLM / Voice / Embeddings moved to the Connections page. */}
<Route path="llm" element={<Navigate to="/connections?tab=llm" replace />} />
<Route
path="embeddings"
element={<Navigate to="/connections?tab=embeddings" replace />}
/>
<Route path="usage" element={wrapSettingsPage(<UsagePanel />)} />
<Route path="voice" element={<Navigate to="/connections?tab=voice" replace />} />
<Route path="personality" element={wrapSettingsPage(<PersonalityPanel />)} />
<Route path="agents" element={wrapSettingsPage(<AgentsPanel />)} />
<Route path="agents/new" element={wrapSettingsPage(<AgentEditorPage />)} />
<Route path="agents/edit/:id" element={wrapSettingsPage(<AgentEditorPage />)} />
{/* Top-level agent profiles (soul, memory, skills, MCP, connectors). */}
<Route path="profiles" element={wrapSettingsPage(<ProfilesPanel />)} />
<Route path="profiles/new" element={wrapSettingsPage(<ProfileEditorPage />)} />
<Route path="profiles/edit/:id" element={wrapSettingsPage(<ProfileEditorPage />)} />
<Route path="agent-access" element={wrapSettingsPage(<AgentAccessPanel />)} />
<Route path="activity-level" element={wrapSettingsPage(<AgentActivityPanel />)} />
<Route path="sandbox-settings" element={wrapSettingsPage(<SandboxSettingsPanel />)} />
<Route path="approval-history" element={wrapSettingsPage(<ApprovalHistoryPanel />)} />
{/* ── Data ────────────────────────────────────────────────── */}
<Route path="memory-sync" element={wrapSettingsPage(<MemorySyncPanel />)} />
<Route path="wallet-balances" element={wrapSettingsPage(<WalletBalancesPanel />)} />
<Route path="recovery-phrase" element={wrapSettingsPage(<RecoveryPhrasePanel />)} />
{/* ── Connections ─────────────────────────────────────────── */}
<Route path="integrations" element={wrapSettingsPage(<IntegrationsPanel />)} />
<Route
path="screen-intelligence"
element={wrapSettingsPage(<ScreenIntelligencePanel />)}
/>
<Route path="tools" element={wrapSettingsPage(<ToolsPanel />)} />
<Route path="companion" element={wrapSettingsPage(<CompanionPanel />)} />
<Route path="autocomplete" element={wrapSettingsPage(<AutocompletePanel />)} />
{/* ── System ──────────────────────────────────────────────── */}
<Route path="developer-options" element={wrapSettingsPage(<DeveloperOptionsPanel />)} />
<Route path="about" element={wrapSettingsPage(<AboutPanel />)} />
{/* ── Developer & Diagnostics leaf panels ─────────────────── */}
<Route
path="tool-policy-diagnostics"
element={wrapSettingsPage(<ToolPolicyDiagnosticsPanel />)}
/>
<Route path="mcp-server" element={wrapSettingsPage(<McpServerPanel />)} />
{/* Search engine settings moved to the Connections page. */}
<Route path="search" element={<Navigate to="/connections?tab=search" replace />} />
<Route path="agent-chat" element={wrapSettingsPage(<AgentChatPanel />)} />
<Route path="cron-jobs" element={wrapSettingsPage(<CronJobsPanel />)} />
<Route path="tasks" element={wrapSettingsPage(<TasksPanel />)} />
<Route path="automations" element={wrapSettingsPage(<WorkflowsTab />)} />
<Route path="dev-workflow" element={wrapSettingsPage(<DevWorkflowPanel />)} />
<Route path="skills-runner" element={wrapSettingsPage(<WorkflowRunnerPanel />)} />
<Route
path="screen-awareness-debug"
element={wrapSettingsPage(<ScreenAwarenessDebugPanel />)}
/>
<Route path="autocomplete-debug" element={wrapSettingsPage(<AutocompleteDebugPanel />)} />
<Route path="voice-debug" element={wrapSettingsPage(<VoiceDebugPanel />)} />
<Route path="local-model-debug" element={wrapSettingsPage(<LocalModelDebugPanel />)} />
<Route path="webhooks-debug" element={wrapSettingsPage(<WebhooksDebugPanel />)} />
<Route path="event-log" element={wrapSettingsPage(<EventLogPanel />)} />
<Route path="model-health" element={wrapSettingsPage(<ModelHealthPanel />)} />
{/* Knowledge & Memory panels moved to the Brain page. */}
<Route path="memory-data" element={<Navigate to="/brain?tab=memory-data" replace />} />
<Route path="memory-debug" element={<Navigate to="/brain?tab=memory-debug" replace />} />
<Route
path="analysis-views"
element={<Navigate to="/brain?tab=analysis-views" replace />}
/>
<Route path="intelligence" element={<Navigate to="/brain?tab=intelligence" replace />} />
<Route path="composio-triggers" element={wrapSettingsPage(<ComposioTriagePanel />)} />
<Route path="permissions" element={wrapSettingsPage(<PermissionsPanel />)} />
{/* ── Legacy slugs → redirects (deep-link compatibility) ──── */}
{/* Old hub pages */}
<Route path="ai" element={<Navigate to="/connections?tab=llm" replace />} />
<Route path="agents-settings" element={<Navigate to="/settings/agents" replace />} />
<Route
path="features"
element={<Navigate to="/settings/screen-intelligence" replace />}
/>
<Route path="crypto" element={<Navigate to="/settings/wallet-balances" replace />} />
<Route
path="notifications-hub"
element={<Navigate to="/settings/notifications" replace />}
/>
<Route path="composio" element={<Navigate to="/settings/integrations" replace />} />
{/* Merged Usage & Limits page */}
<Route path="heartbeat" element={<Navigate to="/settings/usage#background" replace />} />
<Route
path="ledger-usage"
element={<Navigate to="/settings/usage#background" replace />}
/>
<Route path="cost-dashboard" element={<Navigate to="/settings/usage" replace />} />
{/* Autonomy rate-limit lives inside Agent access now */}
<Route path="autonomy" element={<Navigate to="/settings/agent-access" replace />} />
{/* Merged Personality & Face page */}
<Route path="mascot" element={<Navigate to="/settings/personality#face" replace />} />
<Route path="persona" element={<Navigate to="/settings/personality" replace />} />
{/* Merged Integrations page */}
<Route path="task-sources" element={<Navigate to="/settings/integrations" replace />} />
<Route
path="composio-routing"
element={<Navigate to="/settings/integrations#composio" replace />}
/>
<Route
path="webhooks-triggers"
element={<Navigate to="/settings/integrations#webhooks" replace />}
/>
{/* Notification routing tab */}
<Route
path="notification-routing"
element={<Navigate to="/settings/notifications#routing" replace />}
/>
{/* Fallback */}
<Route path="*" element={<Navigate to="/settings" replace />} />
</Route>
</Routes>
</div>
);
+154 -30
View File
@@ -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) => (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={d} />
</svg>
);
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<ConnectionsTab> = new Set<ConnectionsTab>([
'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 (
<div className="min-h-full">
<div className="min-h-full flex flex-col">
<div className="flex-1 flex items-start justify-center p-4 pt-6">
<div className="w-full max-w-3xl space-y-4">
<div className="h-full">
<TwoPanelLayout
id="connections"
// Max-width applied once to the whole panel (sidebar + content) and
// centered, matching the settings two-pane shell.
className="mx-auto h-full w-full max-w-6xl p-4 pt-6"
defaultSidebarVisible
defaultSidebarWidth={210}
minSidebarWidth={170}
maxSidebarWidth={320}
showDividerHandle={false}
sidebar={
<TwoPaneNav
ariaLabel={t('nav.connections')}
selected={activeTab}
onSelect={value => handleTabChange(value as ConnectionsTab)}
header={
<h1 className="text-base font-bold text-stone-900 dark:text-neutral-100">
{t('nav.connections')}
</h1>
}
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'),
},
],
},
]}
/>
}>
<div className="h-full overflow-y-auto">
<div className="mx-auto w-full max-w-3xl space-y-4 px-2">
{/* <div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<h1 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
@@ -822,17 +948,6 @@ export default function Skills() {
</div>
)}
<PillTabBar<ConnectionsTab>
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' && (
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 shadow-soft animate-fade-up">
<div className="px-1 pb-3 pt-1">
<div className="flex items-center gap-2">
<h2
className="text-sm font-semibold text-stone-900 dark:text-neutral-100"
data-walkthrough="skills-grid">
{t('skills.integrations')}
</h2>
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300 border border-primary-100 dark:border-primary-800/50">
{t('skills.composio.poweredBy')}
</span>
</div>
<h2
className="text-sm font-semibold text-stone-900 dark:text-neutral-100"
data-walkthrough="skills-grid">
{t('skills.integrations')}
</h2>
<p className="mt-0.5 text-[11px] leading-relaxed text-stone-500 dark:text-neutral-400">
{t('skills.integrationsSubtitle')}
</p>
</div>
{showLocalComposioApiKeyBanner && (
<ComposioApiKeyEmptyState
onOpenSettings={() => navigate('/settings/composio-routing')}
onOpenSettings={() => navigate('/settings/integrations#composio')}
/>
)}
{!showLocalComposioApiKeyBanner && (
@@ -997,11 +1107,25 @@ export default function Skills() {
<MeetingBotsCard onToast={addToast} />
</div>
)}
{/* 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) && (
<div className="animate-fade-up">
<SettingsLayoutProvider value={{ inTwoPaneShell: true }}>
{activeTab === 'llm' && <AIPanel />}
{activeTab === 'voice' && <VoicePanel />}
{activeTab === 'embeddings' && <EmbeddingsPanel />}
{activeTab === 'search' && <SearchPanel />}
</SettingsLayoutProvider>
</div>
)}
</>
}
</div>
</div>
</div>
</TwoPanelLayout>
{channelModalDef && (
<ChannelSetupModal definition={channelModalDef} onClose={() => setChannelModalDef(null)} />
+51 -37
View File
@@ -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 (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.developerMenu.composeioTriggers.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
{!embedded && (
<SettingsHeader
title={t('settings.developerMenu.composeioTriggers.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<div className="h-full flex items-center justify-center p-4 pt-6">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-neutral-300 dark:border-neutral-700 border-t-primary-500" />
@@ -41,37 +50,42 @@ export default function Webhooks() {
);
}
const statusActions = (
<div className="flex items-center gap-2">
{/* Bespoke connection status badge — keep intentional visual */}
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${
coreConnected
? 'bg-sage-100 text-sage-700'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400'
}`}>
<span
className={`w-1.5 h-1.5 rounded-full ${
coreConnected ? 'bg-sage-500' : 'bg-neutral-400 dark:bg-neutral-500'
}`}
/>
{coreConnected ? t('skills.connected') : t('skills.disconnect')}
</span>
<Button type="button" variant="secondary" size="xs" onClick={() => void refresh()}>
{t('common.refresh')}
</Button>
</div>
);
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.developerMenu.composeioTriggers.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
action={
<div className="flex items-center gap-2">
{/* Bespoke connection status badge — keep intentional visual */}
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${
coreConnected
? 'bg-sage-100 text-sage-700'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400'
}`}>
<span
className={`w-1.5 h-1.5 rounded-full ${
coreConnected ? 'bg-sage-500' : 'bg-neutral-400 dark:bg-neutral-500'
}`}
/>
{coreConnected ? t('skills.connected') : t('skills.disconnect')}
</span>
<Button type="button" variant="secondary" size="xs" onClick={() => void refresh()}>
{t('common.refresh')}
</Button>
</div>
}
/>
{!embedded && (
<SettingsHeader
title={t('settings.developerMenu.composeioTriggers.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
action={statusActions}
/>
)}
<div className="p-4 space-y-4">
{embedded && <div className="flex justify-end">{statusActions}</div>}
{error && <div className="p-3 rounded-lg bg-coral-50 text-coral-700 text-sm">{error}</div>}
{/* Archive paths info — bespoke data-display layout, kept as-is */}
+5 -4
View File
@@ -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(<Brain />);
renderWithProviders(<Brain />);
});
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(<Brain />);
renderWithProviders(<Brain />);
});
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(<Brain />);
renderWithProviders(<Brain />);
});
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
@@ -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<string, unknown> = {}) {
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: <div className="flex-1 overflow-y-auto"> (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<typeof buildStore> | 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
@@ -70,7 +70,7 @@ describe('Skills page — Channels grid', () => {
renderWithProviders(<Skills />, { 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(<Skills />, { 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(<Skills />, { 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' });
@@ -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', () => {
@@ -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(<Skills />, { 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(<Skills />, { 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(<Skills />, { 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(<Skills />, { 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();
});
@@ -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(<Skills />, { 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(<Skills />, { 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();
});
});
@@ -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(<Skills />, { 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' })
@@ -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(<Skills />, { 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 });
@@ -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', () => {
@@ -704,7 +704,7 @@ function TaskSourceControls({ disabled, compact }: { disabled: boolean; compact:
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => navigate('/settings/task-sources')}
onClick={() => navigate('/settings/integrations')}
className="text-[11px] font-medium text-ocean-600 hover:text-ocean-700 dark:text-ocean-300 dark:hover:text-ocean-200">
{t('conversations.taskKanban.sources.manage')}
</button>
@@ -29,7 +29,7 @@ export const CUSTOM_WIZARD_ROUTES: Record<CustomStepKey, string> = {
export const CUSTOM_WIZARD_SETTINGS_ROUTES: Record<CustomStepKey, string> = {
inference: '/settings/llm',
voice: '/settings/voice',
oauth: '/settings/composio-routing',
oauth: '/settings/integrations#composio',
search: '/settings/tools',
embeddings: '/settings/embeddings',
activity: '/settings/activity-level',
+8 -5
View File
@@ -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,
+77
View File
@@ -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 });
});
});
});
+90
View File
@@ -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<string, PanelLayout>;
}
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<PanelLayout> }>
) => {
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<PanelLayout>) =>
(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;
-6
View File
@@ -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<string>) => {
delete state.activeThreadIds[action.payload];
},
setThreadSidebarVisible: (state, action: PayloadAction<boolean>) => {
state.threadSidebarVisible = action.payload;
},
clearStaleThread: (state, action: PayloadAction<string>) => {
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,
+6 -1
View File
@@ -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(() => {});
}
+2
View File
@@ -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,
@@ -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 });
@@ -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;
}
@@ -73,7 +73,7 @@ async function bootSkills(page: Page, userId: string): Promise<void> {
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/
);
@@ -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();
});
});
+5 -5
View File
@@ -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();
@@ -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();
+4 -4
View File
@@ -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' },
];
@@ -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();
@@ -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();
});
});
@@ -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();
});
});
@@ -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();
}

Some files were not shown because too many files have changed in this diff Show More