feat(ui): unify settings panels onto PanelPage + ChipTabs layout primitives (#3646)

This commit is contained in:
Steven Enamakel
2026-06-13 01:00:25 -07:00
committed by GitHub
parent 190329c2c3
commit 427bdbb7d4
119 changed files with 1940 additions and 1485 deletions
@@ -0,0 +1,62 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import ChipTabs, { type ChipTabItem } from './ChipTabs';
type TabId = 'one' | 'two' | 'three';
const items: ChipTabItem<TabId>[] = [
{ id: 'one', label: 'One' },
{ id: 'two', label: 'Two' },
{ id: 'three', label: 'Three' },
];
describe('ChipTabs', () => {
it('renders a tablist with one chip per item by default', () => {
render(<ChipTabs items={items} value="one" onChange={() => {}} ariaLabel="Sections" />);
const list = screen.getByRole('tablist', { name: 'Sections' });
expect(list).toBeInTheDocument();
expect(screen.getAllByRole('tab')).toHaveLength(3);
});
it('marks the active chip with aria-selected', () => {
render(<ChipTabs items={items} value="two" onChange={() => {}} testIdPrefix="t" />);
expect(screen.getByTestId('t-two')).toHaveAttribute('aria-selected', 'true');
expect(screen.getByTestId('t-one')).toHaveAttribute('aria-selected', 'false');
expect(screen.getByTestId('t-three')).toHaveAttribute('aria-selected', 'false');
});
it('emits onChange with the clicked chip id', () => {
const onChange = vi.fn();
render(<ChipTabs items={items} value="one" onChange={onChange} testIdPrefix="t" />);
fireEvent.click(screen.getByTestId('t-three'));
expect(onChange).toHaveBeenCalledWith('three');
});
it('uses an explicit per-item testId over the prefix', () => {
render(
<ChipTabs
items={[{ id: 'one', label: 'One', testId: 'custom-chip' }]}
value="one"
onChange={() => {}}
testIdPrefix="t"
/>
);
expect(screen.getByTestId('custom-chip')).toBeInTheDocument();
expect(screen.queryByTestId('t-one')).not.toBeInTheDocument();
});
it('renders navigation semantics with aria-current when as="nav"', () => {
render(<ChipTabs items={items} value="two" onChange={() => {}} as="nav" ariaLabel="Sub nav" />);
expect(screen.getByRole('navigation', { name: 'Sub nav' })).toBeInTheDocument();
// No tab roles in nav mode.
expect(screen.queryAllByRole('tab')).toHaveLength(0);
expect(screen.getByText('Two')).toHaveAttribute('aria-current', 'page');
expect(screen.getByText('One')).not.toHaveAttribute('aria-current');
});
});
+110
View File
@@ -0,0 +1,110 @@
import type { ReactNode } from 'react';
const namespace = 'chip-tabs';
function debug(message: string, payload?: Record<string, unknown>) {
if (import.meta.env.DEV) {
console.debug(`[${namespace}] ${message}`, payload ?? {});
}
}
export interface ChipTabItem<T extends string> {
/** Stable id; selected when it equals `value` and emitted via `onChange`. */
id: T;
/** Visible chip label (string or custom node). */
label: ReactNode;
/**
* Per-chip `data-testid`. Falls back to `${testIdPrefix}-${id}` when a
* `testIdPrefix` is set on the bar, otherwise no testid is emitted.
*/
testId?: string;
}
export interface ChipTabsProps<T extends string> {
/** Chips to render, left to right. */
items: ChipTabItem<T>[];
/** Currently active chip id. */
value: T;
/** Called with the chip id when a chip is clicked. */
onChange: (id: T) => void;
/**
* Accessibility semantics for the row:
* - `'tab'` (default): `role="tablist"` + `role="tab"` / `aria-selected`. For
* in-page tab bars that swap content without changing route.
* - `'nav'`: `role="navigation"` + `aria-current`. For chips that are real
* route links (e.g. the settings sub-nav siblings).
*/
as?: 'tab' | 'nav';
/** Accessible label for the chip row. */
ariaLabel?: string;
/** `data-testid` for the chip row container. */
testId?: string;
/** Prefix used to derive each chip's `data-testid` (`${prefix}-${id}`). */
testIdPrefix?: string;
/**
* Extra classes on the row container. Defaults provide the canonical settings
* spacing (`px-4 pt-3 pb-3`); pass a value to override padding for hosts that
* already supply their own gutter.
*/
className?: string;
}
/** Canonical chip-row spacing — its own gutter so content below sits correctly. */
const DEFAULT_ROW_CLASS = 'flex flex-wrap gap-1.5 px-4 pt-3 pb-3';
const baseChipClass = 'rounded-full px-3 py-1 text-xs font-medium transition-colors';
const activeChipClass = 'bg-stone-800 text-white dark:bg-neutral-100 dark:text-neutral-900';
const inactiveChipClass =
'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';
/**
* Standard pill/chip tab bar — the look first shipped on Settings → Account and
* its sibling sub-nav. Use it to replace bespoke underline-tab rows and ad-hoc
* chip strips so every "switch between sibling views" surface reads the same.
*
* Presentational and controlled: the host owns the active `value` (route hash,
* query param, or local state) and reacts to `onChange`. Pick `as="tab"` for
* content-swapping tab bars and `as="nav"` for chips that are route links.
*/
export default function ChipTabs<T extends string>({
items,
value,
onChange,
as = 'tab',
ariaLabel,
testId,
testIdPrefix,
className = DEFAULT_ROW_CLASS,
}: ChipTabsProps<T>) {
const isNav = as === 'nav';
return (
<div
className={className}
role={isNav ? 'navigation' : 'tablist'}
aria-label={ariaLabel}
data-testid={testId}>
{items.map(item => {
const active = item.id === value;
const chipTestId = item.testId ?? (testIdPrefix ? `${testIdPrefix}-${item.id}` : undefined);
return (
<button
key={item.id}
type="button"
data-testid={chipTestId}
role={isNav ? undefined : 'tab'}
aria-selected={isNav ? undefined : active}
aria-current={isNav ? (active ? 'page' : undefined) : undefined}
onClick={() => {
debug('select', { id: item.id });
onChange(item.id);
}}
className={`${baseChipClass} ${active ? activeChipClass : inactiveChipClass}`}>
{item.label}
</button>
);
})}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import type { ReactNode } from 'react';
export interface PanelHeaderProps {
/** Sub-title / hint, muted. The primary header content now that titles are gone. */
description?: ReactNode;
/** Leading control before the description row (e.g. a back button). */
leading?: ReactNode;
/** Right-aligned action(s) (e.g. refresh / add). */
action?: ReactNode;
/** Extra content rendered below the description (e.g. a chip row). */
children?: ReactNode;
/** Padding/layout classes for the band. */
className?: string;
/** Surface background for the band. */
bgClassName?: string;
}
// Horizontal padding matches the canonical body padding (`p-4`) so the
// description lines up with the content beneath it — no extra indent.
export const DEFAULT_PANEL_HEADER_CLASS = 'px-4 pt-4 pb-3';
// Slightly off the white/neutral-900 body so the fixed header reads as its own
// band (paired with the body's hairline top border).
export const DEFAULT_PANEL_HEADER_BG = 'bg-stone-50 dark:bg-neutral-800/40';
/**
* The fixed header band shared by {@link PanelScaffold} (panel header) and
* {@link PanelPage} (page chrome above the chips). Renders an optional control
* row (leading + action), an optional description, and arbitrary extra content
* below (e.g. chips) — presentational, no scroll of its own.
*
* Titles were intentionally dropped: the sidebar, bottom bar and chip row
* already name the view, so the band leads with the description instead.
*/
export default function PanelHeader({
description,
leading,
action,
children,
className = DEFAULT_PANEL_HEADER_CLASS,
bgClassName = DEFAULT_PANEL_HEADER_BG,
}: PanelHeaderProps) {
const hasControlRow = leading != null || action != null;
return (
<div className={`${bgClassName} ${className}`}>
{hasControlRow && (
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center">{leading}</div>
{action != null && <div className="flex-shrink-0">{action}</div>}
</div>
)}
{description != null && (
<p className="text-sm text-stone-500 dark:text-neutral-400">{description}</p>
)}
{children}
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import type { ReactNode } from 'react';
import ChipTabs, { type ChipTabItem } from './ChipTabs';
import PanelHeader, { DEFAULT_PANEL_HEADER_BG } from './PanelHeader';
import PanelScaffold from './PanelScaffold';
export interface PanelPageTab<T extends string = string> {
/** Stable id; selected when it equals `value`. */
id: T;
/** Chip label. */
label: ReactNode;
/** Optional scaffold sub-title for this tab (the chip usually suffices). */
description?: ReactNode;
/** Scrollable content for this tab. */
content: ReactNode;
/**
* Body spacing for this tab. Defaults to `''` (no padding) because tab bodies
* are usually embedded sub-panels that self-pad; pass the canonical
* `p-4 space-y-5` for raw content.
*/
contentClassName?: string;
/** Override the chip's `data-testid`. */
chipTestId?: string;
}
export interface PanelPageProps<T extends string = string> {
/** Page description, shown above any chips. Titles are inferred from the chrome. */
description?: ReactNode;
/** Leading node before the description (e.g. a back button). */
leading?: ReactNode;
/** Right-aligned page action(s). */
action?: ReactNode;
/**
* Chip tabs. When provided, the page renders a chip row and swaps the body to
* the active tab's content. Omit for a single-body panel (use `children`).
*/
tabs?: PanelPageTab<T>[];
/** Active tab id (controlled). */
value?: T;
/** Called with the chip id when a tab is selected. */
onChange?: (id: T) => void;
/** Accessible label for the chip row. */
tabsAriaLabel?: string;
/** Prefix for each chip's `data-testid` (`${prefix}-${id}`). */
tabsTestIdPrefix?: string;
/** Single-body content (when there are no `tabs`). */
children?: ReactNode;
/** Body spacing for the single-body case. Defaults to `p-4 space-y-5`. */
contentClassName?: string;
className?: string;
testId?: string;
}
const DEFAULT_CONTENT_CLASS = 'p-4 space-y-5';
/**
* The standard panel page: an optional fixed header (description) and an
* optional chip row, above one or more scrollable {@link PanelScaffold} bodies.
* A hairline border separates the fixed chrome from the scrolling content.
*
* - **No `tabs`** → a single scaffold whose header is the page description and
* whose body is `children`.
* - **With `tabs`** → a fixed page header + chip row, then the active tab's
* content in its own scaffold.
*
* Either way the page fills its parent's height and exposes exactly one vertical
* scroll (the active body). Titles are intentionally absent — the sidebar,
* bottom bar and chips name the view; reach for `description` when a hint helps.
*/
export default function PanelPage<T extends string = string>({
description,
leading,
action,
tabs,
value,
onChange,
tabsAriaLabel,
tabsTestIdPrefix,
children,
contentClassName = DEFAULT_CONTENT_CLASS,
className = '',
testId,
}: PanelPageProps<T>) {
const tabList = tabs ?? [];
const hasTabs = tabList.length > 0;
// Single-body panel: the page header *is* the scaffold header.
if (!hasTabs) {
return (
<PanelScaffold
className={className}
testId={testId}
description={description}
leading={leading}
action={action}
contentClassName={contentClassName}>
{children}
</PanelScaffold>
);
}
const active = tabList.find(t => t.id === value) ?? tabList[0];
const chipItems: ChipTabItem<T>[] = tabList.map(t => ({
id: t.id,
label: t.label,
testId: t.chipTestId,
}));
return (
<div className={`relative flex h-full min-h-0 flex-col ${className}`} data-testid={testId}>
{/* Fixed page chrome: optional description, then the chip row. */}
<PanelHeader
description={description}
leading={leading}
action={action}
className="flex-shrink-0 px-4 pt-4 pb-3"
bgClassName={DEFAULT_PANEL_HEADER_BG}>
<ChipTabs
className="flex flex-wrap gap-1.5 pt-2"
ariaLabel={tabsAriaLabel}
testIdPrefix={tabsTestIdPrefix}
items={chipItems}
value={active.id}
onChange={id => onChange?.(id)}
/>
</PanelHeader>
{/* Active tab body — its own scaffold owns the scroll. The border marks the
seam below the chips. */}
<div className="min-h-0 flex-1">
<PanelScaffold
description={active.description}
contentClassName={active.contentClassName ?? ''}
bodyBorder>
{active.content}
</PanelScaffold>
</div>
</div>
);
}
@@ -0,0 +1,96 @@
import type { ReactNode } from 'react';
import PanelHeader, { DEFAULT_PANEL_HEADER_BG, DEFAULT_PANEL_HEADER_CLASS } from './PanelHeader';
export interface PanelScaffoldProps {
/** Fixed sub-title rendered in a muted tone (titles are inferred from chrome). */
description?: ReactNode;
/** Leading node before the description (e.g. a back button); brings its own spacing. */
leading?: ReactNode;
/** Right-aligned header action(s) (e.g. a refresh or "add" button). */
action?: ReactNode;
/**
* Extra content pinned inside the fixed header, below the description — e.g. a
* {@link ChipTabs} row that should stay visible while the body scrolls.
*/
headerExtra?: ReactNode;
/** Scrollable body content. */
children: ReactNode;
/** Extra classes on the scaffold root. */
className?: string;
/**
* Classes for the scrollable body wrapper. Defaults to the canonical settings
* spacing (`p-4 space-y-5`); pass `''` when the body already supplies its
* own padding (e.g. an embedded sub-panel).
*/
contentClassName?: string;
/** Classes for the fixed header band. */
headerClassName?: string;
/** Background applied to the fixed header band. */
headerBgClassName?: string;
/**
* Draw a hairline border between the fixed header and the scrollable body for
* a clear separation. Defaults to on whenever a header is present; force it
* (e.g. when the chrome above lives in a parent, as in {@link PanelPage} tabs).
*/
bodyBorder?: boolean;
testId?: string;
}
const DEFAULT_CONTENT_CLASS = 'p-4 space-y-5';
const BODY_BORDER_CLASS = 'border-t border-stone-200 dark:border-neutral-800';
/**
* Standard scaffold: a fixed header ({@link PanelHeader}) carrying an optional
* description (plus leading/action/headerExtra slots) above a scrollable body.
* The header never scrolls; only `children` do, and a hairline border marks the
* seam between them.
*
* The scaffold fills its parent's height and owns the *only* vertical scroll in
* its subtree — relying on an unbroken height chain from a bounded ancestor (in
* settings, the two-pane content pane). With no bounded height it degrades
* gracefully: the body grows and the nearest ancestor scroller takes over.
*
* Presentational. For the full page pattern (description + chips over one or
* more scaffolds), use {@link PanelPage}, which composes this.
*/
export default function PanelScaffold({
description,
leading,
action,
headerExtra,
children,
className = '',
contentClassName = DEFAULT_CONTENT_CLASS,
headerClassName = DEFAULT_PANEL_HEADER_CLASS,
headerBgClassName = DEFAULT_PANEL_HEADER_BG,
bodyBorder,
testId,
}: PanelScaffoldProps) {
const hasHeader = description != null || leading != null || action != null || headerExtra != null;
// Only separate the body when the header carries *visible* content. `leading`
// alone is usually a route-aware back button that renders nothing on wide
// viewports, so it shouldn't draw a hairline under an otherwise-empty band.
const hasVisibleHeader = description != null || action != null || headerExtra != null;
const showBorder = bodyBorder ?? hasVisibleHeader;
return (
<div className={`relative flex h-full min-h-0 flex-col ${className}`} data-testid={testId}>
{hasHeader && (
<PanelHeader
description={description}
leading={leading}
action={action}
className={`flex-shrink-0 ${headerClassName}`}
bgClassName={headerBgClassName}>
{headerExtra}
</PanelHeader>
)}
<div
className={`min-h-0 flex-1 overflow-y-auto ${showBorder ? BODY_BORDER_CLASS : ''} ${contentClassName}`}>
{children}
</div>
</div>
);
}
+54 -16
View File
@@ -93,6 +93,13 @@ export interface TwoPanelLayoutProps {
* front-and-center. Defaults to true.
*/
showDividerHandle?: boolean;
/**
* Join the two panes into a single bordered card with no gap between them: the
* shared edge becomes a flush, hairline drag divider. This is the default for
* every two-pane surface; pass `false` for the legacy split-card look with a
* gutter divider (no current callers).
*/
seamless?: boolean;
}
/** Default card look shared by both panes. */
@@ -128,6 +135,7 @@ export default function TwoPanelLayout({
paneClassName = DEFAULT_PANE_CLASS,
showCollapsedRail = false,
showDividerHandle = true,
seamless = true,
}: TwoPanelLayoutProps) {
const { t } = useT();
const dispatch = useAppDispatch();
@@ -239,12 +247,16 @@ export default function TwoPanelLayout({
[commitWidth, persistedWidth, keyboardStep]
);
return (
<div className={`flex min-h-0 ${className}`}>
// In seamless mode the card lives on the wrapper that holds both panes, so the
// panes themselves carry no border/rounding and sit flush against the divider.
const paneCard = seamless ? '' : paneClassName;
const panes = (
<>
{isOpen && (
<>
<div
className={`flex-shrink-0 min-w-0 overflow-hidden ${paneClassName} ${sidebarClassName}`}
className={`flex-shrink-0 min-w-0 overflow-hidden ${paneCard} ${sidebarClassName}`}
style={{ width }}
data-testid={`two-panel-sidebar-${id}`}>
{sidebar}
@@ -263,19 +275,35 @@ export default function TwoPanelLayout({
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'
}`}
className={
seamless
? // Flush hairline seam: 1px visible line, wider invisible hit
// area, highlights on hover/focus.
'group relative w-px flex-shrink-0 cursor-col-resize select-none self-stretch bg-stone-200 dark:bg-neutral-800 focus:outline-none'
: `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'
}`}
/>
{seamless ? (
<>
{/* Wider transparent grab strip straddling the 1px seam; z-10
keeps it above the adjacent panes so it stays grabbable. */}
<span className="absolute inset-y-0 -left-1 -right-1 z-10" />
{/* The seam line itself, brightened on hover/focus. */}
<span className="absolute inset-0 transition-colors group-hover:bg-primary-400 group-focus:bg-primary-500" />
</>
) : (
/* 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>
</>
)}
@@ -295,9 +323,19 @@ export default function TwoPanelLayout({
</button>
)}
<div className={`flex-1 min-w-0 overflow-hidden ${paneClassName} ${contentClassName}`}>
<div className={`flex-1 min-w-0 overflow-hidden ${paneCard} ${contentClassName}`}>
{children}
</div>
</>
);
return (
<div className={`flex min-h-0 ${className}`}>
{seamless ? (
<div className={`flex min-h-0 flex-1 overflow-hidden ${DEFAULT_PANE_CLASS}`}>{panes}</div>
) : (
panes
)}
</div>
);
}
@@ -0,0 +1,70 @@
import { useInRouterContext, useLocation } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { useSettingsLayout } from '../layout/SettingsLayoutContext';
interface SettingsBackButtonProps {
/** Invoked when the button is pressed (typically `navigateBack`). */
onBack?: () => void;
}
/**
* Route-aware back button shared by {@link SettingsHeader} and panels built on
* {@link PanelScaffold}. Encapsulates the visibility rules so both render the
* exact same affordance:
*
* - Hidden entirely when there's no `onBack`, or when a panel is embedded
* *outside* the settings route tree (e.g. Brain / Connections host their own
* navigation and the settings `onBack` would jump away from the host).
* - Inside the two-pane settings shell, top-level destinations
* (`/settings/<slug>`) hide it on md+ — the sidebar owns navigation there —
* while nested pages keep it at every width.
*
* Returns `null` when it should not show, so callers can drop it into a slot
* unconditionally.
*/
const SettingsBackButton = ({ onBack }: SettingsBackButtonProps) => {
const inRouter = useInRouterContext();
return inRouter ? (
<RoutedSettingsBackButton onBack={onBack} />
) : (
<SettingsBackButtonView onBack={onBack} pathname="" />
);
};
const RoutedSettingsBackButton = ({ onBack }: SettingsBackButtonProps) => {
const { pathname } = useLocation();
return <SettingsBackButtonView onBack={onBack} pathname={pathname} />;
};
const SettingsBackButtonView = ({
onBack,
pathname,
}: SettingsBackButtonProps & { pathname: string }) => {
const { t } = useT();
const { inTwoPaneShell } = useSettingsLayout();
const isSettingsPath = pathname.startsWith('/settings');
const show = !!onBack && (isSettingsPath || !inTwoPaneShell);
if (!show) return null;
const isTopLevel = pathname.split('/').filter(Boolean).length <= 2;
const className =
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 (
<button onClick={onBack} className={className} aria-label={t('common.back')}>
<svg
className="w-4 h-4 text-stone-500 dark:text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
);
};
export default SettingsBackButton;
@@ -1,8 +1,7 @@
import type { ReactNode } from 'react';
import { useInRouterContext, useLocation } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { useSettingsLayout } from '../layout/SettingsLayoutContext';
import SettingsBackButton from './SettingsBackButton';
interface BreadcrumbItem {
label: string;
@@ -29,79 +28,21 @@ interface SettingsHeaderProps {
action?: ReactNode;
}
/**
* 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 = ({
const SettingsHeader = ({
className = '',
title,
showBackButton = false,
onBack,
action,
pathname,
}: SettingsHeaderProps & { pathname: string }) => {
}: SettingsHeaderProps) => {
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 */}
{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"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
)}
{/* Route-aware back button (hidden when not applicable). */}
{showBackButton && <SettingsBackButton onBack={onBack} />}
{/* Title */}
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
@@ -26,14 +26,14 @@ const SettingsLayout = () => {
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.
// `seamless` joins both panes into one bordered card with a flush,
// draggable hairline seam — no gutter between the nav and the panel.
className="mx-auto h-full w-full max-w-6xl p-4 pt-6"
defaultSidebarVisible
defaultSidebarWidth={288}
minSidebarWidth={220}
maxSidebarWidth={420}
showDividerHandle={false}
seamless
sidebar={
// overflow-hidden so the scroll lives on the sidebar's own content
// area (below the fixed search header), not this wrapper.
@@ -41,11 +41,17 @@ const SettingsLayout = () => {
<SettingsSidebar />
</div>
}>
<div className="h-full overflow-y-auto">
<div className="px-4 pt-4 -mb-4">
{/* Bounded flex column: the sub-nav chips stay pinned at the top while
the routed panel owns the only vertical scroll (its WrappedSettingsPage
/ PanelScaffold). No scroll here — that's what caused the page to
scroll twice. */}
<div className="flex h-full min-h-0 flex-col">
<div className="flex-shrink-0">
<SettingsSubNav />
</div>
<Outlet />
<div className="min-h-0 flex-1">
<Outlet />
</div>
</div>
</TwoPanelLayout>
</SettingsLayoutProvider>
@@ -1,11 +1,14 @@
import { useT } from '../../../lib/i18n/I18nContext';
import ChipTabs, { type ChipTabItem } from '../../layout/ChipTabs';
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.
* Each pill navigates to its own route — no nested hub pages. Rendered with the
* shared {@link ChipTabs} bar (nav semantics) so it matches every other chip
* row in the app.
*/
const SettingsSubNav = () => {
const { t } = useT();
@@ -16,31 +19,29 @@ const SettingsSubNav = () => {
if (siblings.length === 0) return null;
const items: ChipTabItem<string>[] = siblings.map(entry => ({
id: entry.id,
label: t(entry.titleKey),
testId: `settings-subnav-${entry.id}`,
}));
// The siblings always include the current route's own entry; fall back to the
// first chip so the bar still renders if it somehow doesn't.
const value = siblings.some(s => s.id === currentRoute) ? currentRoute : siblings[0].id;
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>
<ChipTabs
as="nav"
ariaLabel={t('nav.settings')}
testId="settings-subnav"
className="flex flex-wrap gap-1.5 px-4 pt-4 pb-3"
items={items}
value={value}
onChange={id => {
const entry = siblings.find(s => s.id === id);
if (entry) navigateToSettings(entryRoute(entry));
}}
/>
);
};
@@ -43,6 +43,11 @@ export const SETTINGS_NAV_ICONS: Record<string, ReactNode> = {
'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'
)
),
profiles: icon(
stroke(
'M5.121 17.804A13 13 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z'
)
),
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')
),
+9 -13
View File
@@ -54,8 +54,9 @@ import {
openhumanHeartbeatTickNow,
} from '../../../utils/tauriCommands/heartbeat';
import { ConfirmationModal } from '../../intelligence/ConfirmationModal';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsSelect, SettingsStatusLine, SettingsSwitch, SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import { ClaudeCodeStatusCard } from './ai/ClaudeCodeStatusCard';
@@ -2709,7 +2710,7 @@ interface AIPanelProps {
const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const { saved, draft, isDirty, save, persist, discard, loading, error, reload } = useAISettings();
// #1574 §4b: advisory re-embed modal, driven by the backend status RPC.
// Logic lives in a unit-testable hook (see useReembedBackfillModal).
@@ -2919,16 +2920,11 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
const sharedModelRef = useMemo(() => inferSharedModelRef(draft.routing), [draft.routing]);
return (
<div className="z-10 relative">
{!embedded && (
<SettingsHeader
title={t('pages.settings.ai.llm')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<PanelPage
className="z-10"
contentClassName=""
description={embedded ? undefined : t('pages.settings.ai.llmDesc')}
leading={embedded ? undefined : <SettingsBackButton onBack={navigateBack} />}>
<div className={embedded ? 'space-y-6' : 'space-y-6 p-4'}>
<ClaudeCodeStatusCard />
{/* ═══════════════════════════════════════════════════════════════
@@ -3477,7 +3473,7 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
}
/>
)}
</div>
</PanelPage>
);
};
@@ -15,15 +15,16 @@ import { useAppSelector } from '../../../store/hooks';
import { APP_VERSION, LATEST_APP_DOWNLOAD_URL } from '../../../utils/config';
import { isTauriEnvironment } from '../../../utils/configPersistence';
import { openUrl } from '../../../utils/openUrl';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsRow, SettingsSection } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import SystemDiagnostics from './SystemDiagnostics';
const AboutPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
// The auto-cadence is already running via the global <AppUpdatePrompt />;
// disable it here so opening the panel doesn't double-trigger probes.
const { phase, info, error, check } = useAppUpdate({ autoCheck: false });
@@ -68,14 +69,11 @@ const AboutPanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.about')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.aboutDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 space-y-4">
{/* Version */}
<SettingsSection>
@@ -175,7 +173,7 @@ const AboutPanel = () => {
relocated here from the retired Developer & Diagnostics page. */}
<SystemDiagnostics />
</div>
</div>
</PanelPage>
);
};
@@ -1,6 +1,7 @@
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import SettingsHeader from '../components/SettingsHeader';
import PanelPage from '../../layout/PanelPage';
import SettingsBackButton from '../components/SettingsBackButton';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import LogoutAndClearActions from '../LogoutAndClearActions';
@@ -12,7 +13,7 @@ import LogoutAndClearActions from '../LogoutAndClearActions';
*/
const AccountPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const { snapshot } = useCoreState();
const user = snapshot.currentUser;
@@ -20,40 +21,35 @@ const AccountPanel = () => {
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>
<PanelPage
className="z-10"
testId="account-panel"
description={t('pages.settings.accountSection.description')}
leading={<SettingsBackButton onBack={navigateBack} />}>
{(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 className="rounded-2xl overflow-hidden border border-stone-200 dark:border-neutral-800">
<LogoutAndClearActions />
</div>
)}
<div className="rounded-2xl overflow-hidden border border-stone-200 dark:border-neutral-800">
<LogoutAndClearActions />
</div>
</div>
</PanelPage>
);
};
@@ -11,8 +11,9 @@ import {
type TrustedAccess,
type TrustedRoot,
} from '../../../utils/tauriCommands';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsBadge,
SettingsEmptyState,
@@ -37,7 +38,7 @@ const ALLOW_TOOL_INSTALL = true;
const AgentAccessPanel = () => {
const { t } = useT();
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
const { navigateBack, navigateToSettings } = useSettingsNavigation();
// Load `level` so we can carry it through when writing other fields, but
// the tier-selection UI lives in PermissionsPanel. Never render tier radios
@@ -234,14 +235,11 @@ const AgentAccessPanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.agentAccess.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.agentAccess.menuDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 pt-2 space-y-5">
{/* Desktop-only notice */}
{!isTauri() && (
@@ -439,7 +437,7 @@ const AgentAccessPanel = () => {
</>
)}
</div>
</div>
</PanelPage>
);
};
@@ -12,18 +12,15 @@ vi.mock('../hooks/useSettingsNavigation', () => ({
}),
}));
// Mock SettingsHeader so the test does not depend on i18n resolution of the
// header title (which varies with the active locale / brand rename). We assert
// the panel wiring instead: that the shared header renders with a back button
// and that the level options drive the RPC.
vi.mock('../components/SettingsHeader', () => ({
default: ({ title, onBack }: { title: string; onBack?: () => void }) => (
<div data-testid="settings-header">
<span data-testid="settings-header-title">{title}</span>
<button type="button" data-testid="settings-header-back" onClick={onBack}>
back
</button>
</div>
// Mock SettingsBackButton so the test does not depend on the route-aware
// visibility rules of the shared back button. We assert the panel wiring
// instead: that the back button renders and drives `navigateBack`, and that the
// level options drive the RPC.
vi.mock('../components/SettingsBackButton', () => ({
default: ({ onBack }: { onBack?: () => void }) => (
<button type="button" data-testid="settings-header-back" onClick={onBack}>
back
</button>
),
}));
@@ -73,18 +70,17 @@ beforeEach(() => {
});
describe('<AgentActivityPanel />', () => {
it('renders the shared SettingsHeader and the five level options once loaded', async () => {
it('renders the back button and the five level options once loaded', async () => {
render(<AgentActivityPanel />);
// Header only renders after the initial load resolves (loading state has no
// header), so this also asserts the panel left the loading state.
await screen.findByTestId('settings-header');
expect(levelButtons()).toHaveLength(5);
// The level options only render after the initial load resolves (the loading
// state has none), so this also asserts the panel left the loading state.
await waitFor(() => expect(levelButtons()).toHaveLength(5));
});
it('invokes the back handler from the SettingsHeader', async () => {
it('invokes the back handler from the back button', async () => {
render(<AgentActivityPanel />);
await screen.findByTestId('settings-header');
await screen.findByTestId('settings-header-back');
fireEvent.click(screen.getByTestId('settings-header-back'));
expect(navigateBack).toHaveBeenCalledTimes(1);
@@ -92,7 +88,7 @@ describe('<AgentActivityPanel />', () => {
it('persists a new level selection via the update RPC', async () => {
render(<AgentActivityPanel />);
await screen.findByTestId('settings-header');
await waitFor(() => expect(levelButtons()).toHaveLength(5));
// The last option is "Always-on" (level 4 -> api key "always_on").
const options = levelButtons();
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { callCoreRpc } from '../../../services/coreRpcClient';
import SettingsHeader from '../components/SettingsHeader';
import PanelPage from '../../layout/PanelPage';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsStatusLine } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -50,7 +51,7 @@ function getCostMax(level: number): number {
export default function AgentActivityPanel() {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [settings, setSettings] = useState<ActivityLevelSettings | null>(null);
const [monthlyCost, setMonthlyCost] = useState<MonthlyCostSummary | null>(null);
const [status, setStatus] = useState<Status>('loading');
@@ -108,17 +109,11 @@ export default function AgentActivityPanel() {
}
return (
<div className="z-10 relative">
{/* Header title intentionally reuses the menu-row label key
(settings.assistant.backgroundActivity) so the page heading always
matches the entry the user clicked — including the "Subconscious"
brand rename — instead of the internal "Agent activity level" copy. */}
<SettingsHeader
title={t('settings.assistant.backgroundActivity')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('activityLevel.description')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="flex flex-col gap-4 p-4">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('activityLevel.description')}
@@ -188,6 +183,6 @@ export default function AgentActivityPanel() {
savingLabel={t('autonomy.statusSaving')}
/>
</div>
</div>
</PanelPage>
);
}
@@ -2,8 +2,9 @@ import { useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { openhumanAgentChat } from '../../../utils/tauriCommands';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsEmptyState,
SettingsSection,
@@ -19,7 +20,7 @@ const STORAGE_KEY = 'openhuman.settings.agentChat.history';
const AgentChatPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [modelOverride, setModelOverride] = useState('');
@@ -82,14 +83,11 @@ const AgentChatPanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('chat.agentChat')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.developerMenu.agentChat.desc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 space-y-4">
<SettingsSection title={t('chat.overrides')} description={t('chat.agentChatDesc')}>
<div className="px-4 py-3 grid gap-3 md:grid-cols-2">
@@ -168,7 +166,7 @@ const AgentChatPanel = () => {
</div>
</SettingsSection>
</div>
</div>
</PanelPage>
);
};
@@ -24,8 +24,9 @@ import {
type AgentRegistryEntry,
type AgentToolInfo,
} from '../../../services/api/agentRegistryApi';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsRow,
SettingsSection,
@@ -182,15 +183,6 @@ const AgentEditorPage = () => {
}
};
const title = isCreate
? t('settings.agents.editor.createTitle')
: name || t('settings.agents.editor.editTitle');
const breadcrumbs = [
{ label: 'Settings', onClick: () => navigate('/settings') },
{ label: t('settings.agents.title'), onClick: () => navigate('/settings/agents') },
];
const selectValue = customModelMode ? CUSTOM_MODEL : model;
const onModelSelect = (value: string) => {
@@ -204,9 +196,11 @@ const AgentEditorPage = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader title={title} showBackButton onBack={backToList} breadcrumbs={breadcrumbs} />
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.agents.subtitle')}
leading={<SettingsBackButton onBack={backToList} />}>
<div className="p-4">
{loading ? (
<div className="flex items-center justify-center py-12 text-neutral-400 dark:text-neutral-500">
@@ -475,7 +469,7 @@ const AgentEditorPage = () => {
onClose={() => setToolsOpen(false)}
/>
)}
</div>
</PanelPage>
);
};
@@ -13,8 +13,9 @@ import { useNavigate } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { agentRegistryApi, type AgentRegistryEntry } from '../../../services/api/agentRegistryApi';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsBadge, SettingsEmptyState, SettingsSwitch } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -23,7 +24,7 @@ const ORCHESTRATOR_ID = 'orchestrator';
const AgentsPanel = () => {
const { t } = useT();
const navigate = useNavigate();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [agents, setAgents] = useState<AgentRegistryEntry[]>([]);
const [loading, setLoading] = useState(true);
@@ -92,14 +93,11 @@ const AgentsPanel = () => {
);
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.agents.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.agents.subtitle')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4">
<div className="mb-4 flex items-start justify-between gap-3">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
@@ -147,7 +145,7 @@ const AgentsPanel = () => {
</ul>
)}
</div>
</div>
</PanelPage>
);
};
@@ -9,8 +9,9 @@ import GraphCohesionTab from '../../intelligence/GraphCohesionTab';
import MemoryFreshnessTab from '../../intelligence/MemoryFreshnessTab';
import MemoryTimelineTab from '../../intelligence/MemoryTimelineTab';
import NamespaceOverviewTab from '../../intelligence/NamespaceOverviewTab';
import PanelPage from '../../layout/PanelPage';
import PillTabBar from '../../PillTabBar';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
/**
@@ -33,7 +34,7 @@ type AnalysisView =
const AnalysisViewsPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [activeView, setActiveView] = useState<AnalysisView>('diagram');
const views: { id: AnalysisView; label: string }[] = [
@@ -48,13 +49,11 @@ const AnalysisViewsPanel = () => {
];
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.analysisViews.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.analysisViews.menuDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 space-y-4">
<PillTabBar
items={views.map(view => ({ label: view.label, value: view.id }))}
@@ -72,7 +71,7 @@ const AnalysisViewsPanel = () => {
{activeView === 'paths' && <ConnectionPathTab />}
{activeView === 'namespaces' && <NamespaceOverviewTab />}
</div>
</div>
</PanelPage>
);
};
@@ -13,7 +13,8 @@ import {
type ThemeMode,
} from '../../../store/themeSlice';
import LanguageSelect from '../../LanguageSelect';
import SettingsHeader from '../components/SettingsHeader';
import PanelPage from '../../layout/PanelPage';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsRow, SettingsSection, SettingsSwitch } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -67,7 +68,7 @@ const SystemIcon = (
const AppearancePanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const dispatch = useAppDispatch();
const mode = useAppSelector(state => state.theme.mode);
const fontSize = useAppSelector(state => state.theme.fontSize);
@@ -138,14 +139,11 @@ const AppearancePanel = () => {
];
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.appearance.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.appearance.menuDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 space-y-4">
{/* ── Theme picker — intentional bespoke tile UI ─────────────── */}
<div>
@@ -322,7 +320,7 @@ const AppearancePanel = () => {
/>
</SettingsSection>
</div>
</div>
</PanelPage>
);
};
@@ -7,8 +7,9 @@ import {
type ApprovalDecision,
fetchRecentApprovalDecisions,
} from '../../../services/api/approvalApi';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsBadge,
SettingsEmptyState,
@@ -39,7 +40,7 @@ const DECISION_LABEL_KEY: Record<ApprovalDecision, string> = {
const ApprovalHistoryPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [entries, setEntries] = useState<ApprovalAuditEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -88,14 +89,10 @@ const ApprovalHistoryPanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.approvalHistory.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 pt-2 space-y-5" data-testid="approval-history-panel">
<SettingsSection>
<div className="px-4 py-3 flex items-center justify-between gap-2">
@@ -171,7 +168,7 @@ const ApprovalHistoryPanel = () => {
)}
</SettingsSection>
</div>
</div>
</PanelPage>
);
};
@@ -17,9 +17,10 @@ import {
openhumanAutocompleteStop,
openhumanGetConfig,
} from '../../../utils/tauriCommands';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import Input from '../../ui/Input';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsSection, SettingsStatusLine, SettingsTextArea } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -70,7 +71,7 @@ const parseAutocompleteConfig = (raw: unknown): AutocompleteConfig => {
const AutocompleteDebugPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
// Status & loading
const [status, setStatus] = useState<AutocompleteStatus | null>(null);
@@ -477,14 +478,11 @@ const AutocompleteDebugPanel = () => {
// -------------------------------------------------------------------------
return (
<div className="z-10 relative">
<SettingsHeader
title={t('autocomplete.debugTitle')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.developerMenu.autocomplete.desc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
{/* ------------------------------------------------------------------ */}
{/* Runtime section */}
@@ -772,7 +770,7 @@ const AutocompleteDebugPanel = () => {
{/* ------------------------------------------------------------------ */}
<SettingsStatusLine saving={false} savedNote={message} error={error} savingLabel="" />
</div>
</div>
</PanelPage>
);
};
@@ -11,8 +11,9 @@ import {
openhumanAutocompleteStop,
openhumanGetConfig,
} from '../../../utils/tauriCommands';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsNumberField,
SettingsRow,
@@ -69,7 +70,7 @@ const parseAutocompleteConfig = (raw: unknown): AutocompleteConfig => {
const AutocompletePanel = () => {
const { t } = useT();
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
const { navigateBack, navigateToSettings } = useSettingsNavigation();
const [status, setStatus] = useState<AutocompleteStatus | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -216,14 +217,10 @@ const AutocompletePanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('autocomplete.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 pt-2 space-y-5">
{/* ── Settings ──────────────────────────────────────────────── */}
<SettingsSection title={t('autocomplete.settings')}>
@@ -394,7 +391,7 @@ const AutocompletePanel = () => {
</svg>
</button>
</div>
</div>
</PanelPage>
);
};
@@ -1,23 +1,20 @@
import { useT } from '../../../lib/i18n/I18nContext';
import { BILLING_DASHBOARD_URL } from '../../../utils/links';
import { openUrl } from '../../../utils/openUrl';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const BillingPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
return (
<div className="z-10 relative">
<SettingsHeader
title={t('nav.avatarMenu.billing')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4">
<div className="max-w-xl space-y-4">
<div>
@@ -48,7 +45,7 @@ const BillingPanel = () => {
</div>
</div>
</div>
</div>
</PanelPage>
);
};
@@ -9,14 +9,15 @@ import type {
StopCompanionSessionResult,
} from '../../../store/companionSlice';
import { useAppSelector } from '../../../store/hooks';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsRow, SettingsSection, SettingsStatusLine } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const CompanionPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const companionState = useAppSelector(state => state.companion.state);
const [status, setStatus] = useState<CompanionSessionStatus | null>(null);
@@ -99,14 +100,11 @@ const CompanionPanel = () => {
const sessionActive = status?.active ?? false;
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.companion.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('pages.settings.features.desktopCompanionDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="space-y-4 p-4">
{/* Session status + controls */}
<SettingsSection>
@@ -222,7 +220,7 @@ const CompanionPanel = () => {
savingLabel={t('settings.agentAccess.saving')}
/>
</div>
</div>
</PanelPage>
);
};
@@ -23,8 +23,9 @@ import {
openhumanComposioGetMode,
openhumanComposioSetApiKey,
} from '../../../utils/tauriCommands';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsRow, SettingsSection, SettingsStatusLine, SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -41,7 +42,7 @@ interface ComposioPanelProps {
const ComposioPanel = ({ embedded = false, managedAuthEnabled }: ComposioPanelProps = {}) => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const { snapshot } = useCoreState();
const allowManagedAuth =
managedAuthEnabled ??
@@ -204,37 +205,29 @@ const ComposioPanel = ({ embedded = false, managedAuthEnabled }: ComposioPanelPr
setConfirmGate('idle');
};
const composioDescription = embedded
? undefined
: t('settings.developerMenu.composioRouting.desc');
const composioLeading = embedded ? undefined : <SettingsBackButton onBack={navigateBack} />;
if (loading) {
return (
<div>
{!embedded && (
<SettingsHeader
title={t('settings.composio.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<PanelPage contentClassName="" description={composioDescription} leading={composioLeading}>
<div className={embedded ? '' : 'p-4'}>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.composio.loading')}
</p>
</div>
</div>
</PanelPage>
);
}
return (
<div className="z-10 relative">
{!embedded && (
<SettingsHeader
title={t('settings.composio.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<PanelPage
className="z-10"
contentClassName=""
description={composioDescription}
leading={composioLeading}>
<div className={embedded ? 'space-y-5' : 'p-4 pt-2 space-y-5'}>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.composio.intro')}
@@ -406,7 +399,7 @@ const ComposioPanel = ({ embedded = false, managedAuthEnabled }: ComposioPanelPr
</div>
)}
</div>
</div>
</PanelPage>
);
};
@@ -5,8 +5,9 @@ import {
openhumanGetComposioTriggerSettings,
openhumanUpdateComposioTriggerSettings,
} from '../../../utils/tauriCommands';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsRow,
SettingsSection,
@@ -18,7 +19,7 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const ComposioTriagePanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [triageDisabled, setTriageDisabled] = useState(false);
const [disabledToolkits, setDisabledToolkits] = useState('');
@@ -83,31 +84,26 @@ const ComposioTriagePanel = () => {
if (loading) {
return (
<div className="z-10 relative">
<SettingsHeader
title={t('composio.triageTitle')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.developerMenu.composio.desc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.composio.loading')}
</p>
</div>
</div>
</PanelPage>
);
}
return (
<div className="z-10 relative">
<SettingsHeader
title={t('composio.triageTitle')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.developerMenu.composio.desc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 pt-2 space-y-5">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('composio.triageDesc')}{' '}
@@ -167,7 +163,7 @@ const ComposioTriagePanel = () => {
/>
</div>
</div>
</div>
</PanelPage>
);
};
@@ -13,8 +13,9 @@ import {
openhumanCronRuns,
openhumanCronUpdate,
} from '../../../utils/tauriCommands';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsSection, SettingsStatusLine } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import CoreJobList from './cron/CoreJobList';
@@ -24,7 +25,7 @@ const loadCronJobsLog = createDebug('app:settings:CronJobsPanel:loadCronSkills')
const CronJobsPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const formatCronError = useCallback(
(key: string, message: string) => t(key).replace('{message}', message),
[t]
@@ -187,14 +188,12 @@ const CronJobsPanel = () => {
};
return (
<div className="z-10 relative" data-testid="cron-jobs-panel">
<SettingsHeader
title={t('cron.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
testId="cron-jobs-panel"
description={t('settings.developerMenu.cronJobs.desc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 pt-2 space-y-5">
<SettingsSection title={t('cron.scheduledJobs')} description={t('cron.manageCronJobs')}>
<div className="px-4 pb-4 space-y-4">
@@ -264,7 +263,7 @@ const CronJobsPanel = () => {
onUpdate={handleUpdate}
/>
)}
</div>
</PanelPage>
);
};
@@ -15,8 +15,9 @@ import {
openhumanCronRuns,
openhumanCronUpdate,
} from '../../../utils/tauriCommands/cron';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsRow,
SettingsSection,
@@ -55,7 +56,7 @@ interface GhBranch {
const DevWorkflowPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
// Repo list
const [repos, setRepos] = useState<ComposioGhRepo[]>([]);
@@ -501,14 +502,12 @@ const DevWorkflowPanel = () => {
const canSave = selectedRepo && targetBranch && schedule;
return (
<div data-testid="dev-workflow-panel" className="z-10 relative">
<SettingsHeader
title={t('settings.developerMenu.devWorkflow.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
testId="dev-workflow-panel"
description={t('settings.developerMenu.devWorkflow.desc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="px-4 pt-4 flex flex-col gap-5">
{/* Description */}
<p className="text-sm text-neutral-600 dark:text-neutral-400">
@@ -817,7 +816,7 @@ const DevWorkflowPanel = () => {
</>
)}
</div>
</div>
</PanelPage>
);
};
@@ -16,8 +16,9 @@ import { APP_ENVIRONMENT } from '../../../utils/config';
// TAURI-REACT-6 — into a rejected Promise so the existing `.catch(...)` /
// try/catch handlers see it as a normal IPC failure.
import { safeInvoke as invoke, isTauri } from '../../../utils/tauriCommands/common';
import PanelPage from '../../layout/PanelPage';
import { resetWalkthrough } from '../../walkthrough/AppWalkthrough';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import SettingsMenuItem from '../components/SettingsMenuItem';
import { SettingsSection } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -634,7 +635,7 @@ const LogsFolderRow = () => {
const DeveloperOptionsPanel = () => {
const { t } = useT();
const navigate = useNavigate();
const { navigateToSettings, navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateToSettings, navigateBack } = useSettingsNavigation();
const showSentryTest = APP_ENVIRONMENT === 'staging';
// Trailing actions (restart tour) that don't fit cleanly in any group
@@ -659,14 +660,11 @@ const DeveloperOptionsPanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('devOptions.titleDiagnostics')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.developerDiagnosticsDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
{/* Debug-only sub-sections */}
<div className="p-4 pt-2 space-y-3">
{DEV_GROUPS.map(group => (
@@ -710,7 +708,7 @@ const DeveloperOptionsPanel = () => {
<LogsFolderRow />
{showSentryTest && <SentryTestRow />}
</div>
</div>
</PanelPage>
);
};
@@ -5,8 +5,9 @@ import { useT } from '../../../lib/i18n/I18nContext';
import { callCoreRpc } from '../../../services/coreRpcClient';
import type { ToastNotification } from '../../../types/intelligence';
import { ToastContainer } from '../../intelligence/Toast';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsBadge,
SettingsEmptyState,
@@ -164,7 +165,7 @@ function ConfirmRevokeDialog({
const DevicesPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [devices, setDevices] = useState<PairedDevice[]>([]);
const [loading, setLoading] = useState(true);
@@ -281,19 +282,16 @@ const DevicesPanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('devices.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
action={
<Button type="button" variant="primary" size="xs" onClick={handleOpenPairModal}>
{t('devices.pairIphone')}
</Button>
}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.account.devicesDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}
action={
<Button type="button" variant="primary" size="xs" onClick={handleOpenPairModal}>
{t('devices.pairIphone')}
</Button>
}>
<div className="px-5 pb-3 flex items-center gap-2">
{/* Bespoke beta badge — intentional marketing chip */}
<SettingsBadge variant="warning">{t('devices.betaBadge')}</SettingsBadge>
@@ -384,7 +382,7 @@ const DevicesPanel = () => {
{showPairModal && <PairPhoneModal onClose={handleClosePairModal} onPaired={handlePaired} />}
<ToastContainer notifications={toasts} onRemove={removeToast} />
</div>
</PanelPage>
);
};
@@ -18,8 +18,9 @@ import {
testEmbeddingsConnection,
updateEmbeddingsSettings,
} from '../../../services/api/embeddingsApi';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsBadge,
SettingsRow,
@@ -43,7 +44,7 @@ interface EmbeddingsPanelProps {
const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [settings, setSettings] = useState<EmbeddingsSettings | null>(null);
const [status, setStatus] = useState<Status>({ kind: 'loading' });
@@ -86,15 +87,11 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
if (!settings) {
return (
<div className="z-10 relative">
{!embedded && (
<SettingsHeader
title={t('settings.embeddings.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<PanelPage
className="z-10"
contentClassName=""
description={embedded ? undefined : t('pages.settings.ai.embeddingsDesc')}
leading={embedded ? undefined : <SettingsBackButton onBack={navigateBack} />}>
<div className={embedded ? '' : 'p-4'}>
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 text-xs text-neutral-500 dark:text-neutral-400">
{status.kind === 'loading'
@@ -104,7 +101,7 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
: ''}
</div>
</div>
</div>
</PanelPage>
);
}
@@ -332,16 +329,11 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
}
return (
<div className="z-10 relative">
{!embedded && (
<SettingsHeader
title={t('settings.embeddings.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<PanelPage
className="z-10"
contentClassName=""
description={embedded ? undefined : t('pages.settings.ai.embeddingsDesc')}
leading={embedded ? undefined : <SettingsBackButton onBack={navigateBack} />}>
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
<p className="text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed">
{t('settings.embeddings.description')}
@@ -708,7 +700,7 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
</div>
</div>
)}
</div>
</PanelPage>
);
};
@@ -2,8 +2,9 @@ import { useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { getCoreHttpBaseUrl, getCoreRpcToken } from '../../../services/coreRpcClient';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsSelect, SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -48,7 +49,7 @@ const RECONNECT_DELAY_MS = 3000;
const EventLogPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [entries, setEntries] = useState<EventEntry[]>([]);
const [isLive, setIsLive] = useState(false);
const [filterType, setFilterType] = useState<string>('');
@@ -211,14 +212,12 @@ const EventLogPanel = () => {
const domains = [...new Set(entries.map(e => e.domain))].sort();
return (
<div className="z-10 relative" data-testid="event-log-panel">
<SettingsHeader
title={t('settings.developerMenu.eventLog.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
testId="event-log-panel"
description={t('settings.developerMenu.eventLog.desc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 space-y-4">
{/* Status bar */}
<div className="flex flex-wrap items-center gap-2">
@@ -327,7 +326,7 @@ const EventLogPanel = () => {
</div>
</section>
</div>
</div>
</PanelPage>
);
};
@@ -1,39 +1,40 @@
import { useLocation, useNavigate } from 'react-router-dom';
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import Webhooks from '../../../pages/Webhooks';
import SettingsHeader from '../components/SettingsHeader';
import PanelPage from '../../layout/PanelPage';
import SettingsBackButton from '../components/SettingsBackButton';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import ComposioPanel from './ComposioPanel';
import TaskSourcesPanel from './TaskSourcesPanel';
type TabId = 'task-sources' | 'composio' | 'webhooks';
type TabId = 'task-sources' | 'webhooks';
const TAB_HASH: Record<TabId, string> = {
'task-sources': '',
composio: '#composio',
webhooks: '#webhooks',
};
const TAB_HASH: Record<TabId, string> = { 'task-sources': '', 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.
* (TaskSourcesPanel) and the webhook trigger history/triage (Webhooks page) as
* two tabs under one header. The active tab is reflected in the URL hash
* (`#webhooks`) so deep links and the legacy redirects land on the right view.
* Composio (API key + routing) moved to Connections → API keys.
*/
const IntegrationsPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const location = useLocation();
const navigate = useNavigate();
// Legacy deep link: the old Composio tab lived at `#composio` on this page.
// It now lives under Connections → API keys, so normalize the bookmark.
if (location.hash === '#composio') {
return <Navigate to="/connections?tab=composio-key" replace />;
}
// The router is the single source of truth for the active tab.
const tab: TabId = hashToTab(location.hash);
@@ -41,54 +42,28 @@ const IntegrationsPanel = () => {
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>
<PanelPage<TabId>
className="z-10"
description={t('settings.integrations.menuDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}
tabsAriaLabel={t('settings.integrations.title')}
tabsTestIdPrefix="integrations-tab"
value={tab}
onChange={selectTab}
tabs={[
{
id: 'task-sources',
label: t('settings.taskSources.title'),
content: <TaskSourcesPanel embedded />,
},
{
id: 'webhooks',
label: t('settings.developerMenu.composeioTriggers.title'),
content: <Webhooks embedded />,
},
]}
/>
);
};
@@ -32,7 +32,8 @@ import {
openhumanUpdateLocalAiSettings,
} from '../../../utils/tauriCommands';
import { openhumanGetConfig } from '../../../utils/tauriCommands/config';
import SettingsHeader from '../components/SettingsHeader';
import PanelPage from '../../layout/PanelPage';
import SettingsBackButton from '../components/SettingsBackButton';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import ModelDownloadSection from './local-model/ModelDownloadSection';
import ModelStatusSection from './local-model/ModelStatusSection';
@@ -58,7 +59,7 @@ const statusTone = (state: string): string => {
const LocalModelDebugPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [status, setStatus] = useState<LocalAiStatus | null>(null);
const [assets, setAssets] = useState<LocalAiAssetsStatus | null>(null);
@@ -378,14 +379,11 @@ const LocalModelDebugPanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('localModel.debugTitle')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.developerMenu.localModelDebug.desc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 space-y-4">
<ModelStatusSection
status={status}
@@ -471,7 +469,7 @@ const LocalModelDebugPanel = () => {
onRunTtsTest={() => void runTtsTest()}
/>
</div>
</div>
</PanelPage>
);
};
@@ -7,8 +7,10 @@ import { useT } from '../../../lib/i18n/I18nContext';
// TAURI-REACT-6 — into a rejected Promise that the existing try/catch sees
// as a regular IPC failure.
import { safeInvoke as invoke, isTauri } from '../../../utils/tauriCommands/common';
import ChipTabs from '../../layout/ChipTabs';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsSection } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -101,7 +103,7 @@ interface McpServerPanelProps {
const McpServerPanel = ({ embedded = false }: McpServerPanelProps = {}) => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [binaryInfo, setBinaryInfo] = useState<McpBinaryInfo | null>(null);
const [binaryError, setBinaryError] = useState<string | null>(null);
@@ -168,16 +170,11 @@ const McpServerPanel = ({ embedded = false }: McpServerPanelProps = {}) => {
];
return (
<div className="z-10 relative">
{!embedded && (
<SettingsHeader
title={t('settings.mcpServer.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<PanelPage
className="z-10"
contentClassName=""
description={embedded ? undefined : t('settings.developerMenu.mcpServer.desc')}
leading={embedded ? undefined : <SettingsBackButton onBack={navigateBack} />}>
{/* ----------------------------------------------------------------- */}
{/* Section 1 — Available Tools */}
{/* ----------------------------------------------------------------- */}
@@ -208,32 +205,15 @@ const McpServerPanel = ({ embedded = false }: McpServerPanelProps = {}) => {
title={t('settings.mcpServer.configSectionTitle')}
description={t('settings.mcpServer.configSectionDesc')}>
{/* Client selector tabs */}
<div className="px-4 pt-3">
<div
className="flex gap-1 flex-wrap"
role="tablist"
aria-label={t('settings.mcpServer.clientSelectorAriaLabel')}>
{clients.map(client => (
<button
key={client.id}
type="button"
role="tab"
aria-selected={activeClient === client.id}
onClick={() => {
setActiveClient(client.id);
setOpenConfigError(null);
}}
className={[
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
activeClient === client.id
? 'bg-primary-600 text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-700',
].join(' ')}>
{client.label}
</button>
))}
</div>
</div>
<ChipTabs
ariaLabel={t('settings.mcpServer.clientSelectorAriaLabel')}
items={clients}
value={activeClient}
onChange={id => {
setActiveClient(id);
setOpenConfigError(null);
}}
/>
{/* Binary path error banner */}
{binaryError && (
@@ -287,7 +267,7 @@ const McpServerPanel = ({ embedded = false }: McpServerPanelProps = {}) => {
)}
</SettingsSection>
</div>
</div>
</PanelPage>
);
};
@@ -5,8 +5,9 @@ import type { ToastNotification } from '../../../types/intelligence';
import { MemoryWorkspace } from '../../intelligence/MemoryWorkspace';
import { ToastContainer } from '../../intelligence/Toast';
import { VaultHealthChecklist } from '../../intelligence/VaultHealthChecklist';
import PanelPage from '../../layout/PanelPage';
import MemoryWindowControl from '../components/MemoryWindowControl';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
interface MemoryDataPanelProps {
@@ -17,7 +18,7 @@ interface MemoryDataPanelProps {
const MemoryDataPanel = ({ embedded = false }: MemoryDataPanelProps = {}) => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [toasts, setToasts] = useState<ToastNotification[]>([]);
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
@@ -48,15 +49,11 @@ const MemoryDataPanel = ({ embedded = false }: MemoryDataPanelProps = {}) => {
);
return (
<div className="z-10 relative">
{!embedded && (
<SettingsHeader
title={t('memory.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<PanelPage
className="z-10"
contentClassName=""
description={embedded ? undefined : t('devOptions.memoryInspectionDesc')}
leading={embedded ? undefined : <SettingsBackButton onBack={navigateBack} />}>
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
<section className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 space-y-3">
<h3 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
@@ -94,7 +91,7 @@ const MemoryDataPanel = ({ embedded = false }: MemoryDataPanelProps = {}) => {
<MemoryWorkspace onToast={addToast} />
</div>
<ToastContainer notifications={toasts} onRemove={removeToast} />
</div>
</PanelPage>
);
};
@@ -12,8 +12,9 @@ import {
memoryRecallNamespace,
} from '../../../utils/tauriCommands';
import { MemoryTextWithEntities } from '../../intelligence/MemoryTextWithEntities';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsEmptyState,
SettingsSection,
@@ -27,7 +28,7 @@ import { normalizeMemoryDocuments } from './memoryDebugUtils';
const MemoryDebugPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [documents, setDocuments] = useState<MemoryDebugDocument[]>([]);
const [documentsRaw, setDocumentsRaw] = useState<unknown>(null);
const [documentsNamespaceFilter, setDocumentsNamespaceFilter] = useState('');
@@ -196,14 +197,12 @@ const MemoryDebugPanel = () => {
}, [clearNamespaceInput, refreshAll, t]);
return (
<div className="z-10 relative" data-testid="memory-debug-panel">
<SettingsHeader
title={t('memory.debugTitle')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
testId="memory-debug-panel"
description={t('devOptions.debugPanelsDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 space-y-4">
{/* Documents */}
<SettingsSection title={t('memory.documents')}>
@@ -438,7 +437,7 @@ const MemoryDebugPanel = () => {
</div>
</SettingsSection>
</div>
</div>
</PanelPage>
);
};
@@ -5,7 +5,8 @@ import type { ToastNotification } from '../../../types/intelligence';
import { MemorySourcesRegistry } from '../../intelligence/MemorySourcesRegistry';
import { SyncAuditPanel } from '../../intelligence/SyncAuditPanel';
import { ToastContainer } from '../../intelligence/Toast';
import SettingsHeader from '../components/SettingsHeader';
import PanelPage from '../../layout/PanelPage';
import SettingsBackButton from '../components/SettingsBackButton';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
/**
@@ -22,7 +23,7 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
*/
const MemorySyncPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [toasts, setToasts] = useState<ToastNotification[]>([]);
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
@@ -35,13 +36,11 @@ const MemorySyncPanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.dataSync.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.dataSync.menuDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 space-y-4">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.dataSync.description')}
@@ -55,7 +54,7 @@ const MemorySyncPanel = () => {
</div>
</div>
<ToastContainer notifications={toasts} onRemove={removeToast} />
</div>
</PanelPage>
);
};
@@ -7,8 +7,9 @@ import {
openhumanMigrateHermes,
openhumanMigrateOpenclaw,
} from '../../../utils/tauriCommands/core';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsSection, SettingsSelect, SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -25,7 +26,7 @@ interface MigrationPanelProps {
const MigrationPanel = ({ embedded = false }: MigrationPanelProps = {}) => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [vendor, setVendor] = useState<Vendor>('openclaw');
const [sourcePath, setSourcePath] = useState<string>('');
@@ -134,16 +135,11 @@ const MigrationPanel = ({ embedded = false }: MigrationPanelProps = {}) => {
const reportToRender = appliedReport ?? previewReport;
return (
<div className="z-10 relative">
{!embedded && (
<SettingsHeader
title={t('migration.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<PanelPage
className="z-10"
contentClassName=""
description={embedded ? undefined : t('pages.settings.account.migrationDesc')}
leading={embedded ? undefined : <SettingsBackButton onBack={navigateBack} />}>
<div className="max-w-3xl space-y-6 p-6">
<p className="text-sm text-neutral-600 dark:text-neutral-300">
{t('migration.description')}
@@ -310,7 +306,7 @@ const MigrationPanel = ({ embedded = false }: MigrationPanelProps = {}) => {
</section>
)}
</div>
</div>
</PanelPage>
);
};
@@ -3,8 +3,9 @@ import { useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { callCoreRpc } from '../../../services/coreRpcClient';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsEmptyState, SettingsSelect } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -89,7 +90,7 @@ const BADGE_STYLES: Record<StatusBadge, { bg: string; text: string; label: strin
const ModelHealthPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [models, setModels] = useState<ModelEntry[]>([]);
const [config, setConfig] = useState<HealthConfig>({
hallucination_threshold: 0.1,
@@ -167,13 +168,12 @@ const ModelHealthPanel = () => {
const sortIcon = (col: SortCol) => (sortCol === col ? (sortAsc ? ' ↑' : ' ↓') : '');
return (
<div className="z-10 relative" data-testid="model-health-panel">
<SettingsHeader
title={t('settings.modelHealth.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
testId="model-health-panel"
className="z-10"
contentClassName=""
description={t('settings.modelHealth.desc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 space-y-4">
<div className="flex items-center gap-2 text-xs">
<SettingsSelect
@@ -379,7 +379,7 @@ const ModelHealthPanel = () => {
</div>
</div>
)}
</div>
</PanelPage>
);
};
@@ -1,7 +1,8 @@
import { useLocation, useNavigate } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import SettingsHeader from '../components/SettingsHeader';
import PanelPage from '../../layout/PanelPage';
import SettingsBackButton from '../components/SettingsBackButton';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import NotificationRoutingPanel from './NotificationRoutingPanel';
import NotificationsPanel from './NotificationsPanel';
@@ -21,7 +22,7 @@ const hashToTab = (hash: string): TabId => (hash === '#routing' ? 'routing' : 'p
*/
const NotificationsTabbedPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const location = useLocation();
const navigate = useNavigate();
// The router is the single source of truth for the active tab — hash is the
@@ -32,50 +33,27 @@ const NotificationsTabbedPanel = () => {
navigate(`${location.pathname}${TAB_HASH[next]}`, { replace: true });
};
const tabs: { id: TabId; label: string }[] = [
{ id: 'preferences', label: t('settings.notifications.tabs.preferences') },
{ id: 'routing', label: t('settings.notifications.tabs.routing') },
];
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.notifications')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div
role="tablist"
aria-label={t('settings.notifications')}
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}
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 === 'preferences' ? (
<NotificationsPanel embedded />
) : (
<NotificationRoutingPanel embedded />
)}
</div>
<PanelPage<TabId>
className="z-10"
description={t('settings.notifications.menuDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}
tabsAriaLabel={t('settings.notifications')}
value={tab}
onChange={selectTab}
tabs={[
{
id: 'preferences',
label: t('settings.notifications.tabs.preferences'),
content: <NotificationsPanel embedded />,
},
{
id: 'routing',
label: t('settings.notifications.tabs.routing'),
content: <NotificationRoutingPanel embedded />,
},
]}
/>
);
};
@@ -10,8 +10,9 @@ import {
openhumanUpdateAgentPaths,
openhumanUpdateAutonomySettings,
} from '../../../utils/tauriCommands';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsStatusLine, SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -29,7 +30,7 @@ interface PresetOption {
const PermissionsPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
// Tier presets — built inside the component so titles/descriptions resolve
// through `t()` (i18n). Order matters: it's the display order.
@@ -189,14 +190,10 @@ const PermissionsPanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.permissions.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 space-y-6">
{!isTauri() && (
<p className="text-sm text-coral-600 dark:text-coral-300">
@@ -362,7 +359,7 @@ const PermissionsPanel = () => {
</>
)}
</div>
</div>
</PanelPage>
);
};
@@ -1,7 +1,8 @@
import { useLocation, useNavigate } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import SettingsHeader from '../components/SettingsHeader';
import PanelPage from '../../layout/PanelPage';
import SettingsBackButton from '../components/SettingsBackButton';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import MascotPanel from './MascotPanel';
import PersonaPanel from './PersonaPanel';
@@ -21,7 +22,7 @@ const hashToTab = (hash: string): TabId => (hash === '#face' ? 'face' : 'persona
*/
const PersonalityPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const location = useLocation();
const navigate = useNavigate();
// The router is the single source of truth for the active tab.
@@ -31,47 +32,28 @@ const PersonalityPanel = () => {
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>
<PanelPage<TabId>
className="z-10"
description={t('settings.personalityFace.menuDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}
tabsAriaLabel={t('settings.personalityFace.title')}
tabsTestIdPrefix="personality-tab"
value={tab}
onChange={selectTab}
tabs={[
{
id: 'personality',
label: t('settings.assistant.personality'),
content: <PersonaPanel embedded />,
},
{
id: 'face',
label: t('settings.assistant.faceMascot'),
content: <MascotPanel embedded />,
},
]}
/>
);
};
@@ -9,7 +9,8 @@ import {
listCapabilities,
type PrivacyDataKind,
} from '../../../utils/tauriCommands/aboutApp';
import SettingsHeader from '../components/SettingsHeader';
import PanelPage from '../../layout/PanelPage';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsBadge,
type SettingsBadgeVariant,
@@ -49,7 +50,7 @@ function kindLabel(kind: PrivacyDataKind, t: (key: string) => string): string {
}
const PrivacyPanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const { snapshot, setAnalyticsEnabled, setMeetAutoOrchestratorHandoff } = useCoreState();
const analyticsEnabled = snapshot.analyticsEnabled;
const meetAutoHandoff = snapshot.meetAutoOrchestratorHandoff;
@@ -100,14 +101,12 @@ const PrivacyPanel = () => {
};
return (
<div data-testid="settings-privacy-panel" className="z-10 relative">
<SettingsHeader
title={t('privacy.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
testId="settings-privacy-panel"
className="z-10"
contentClassName=""
description={t('pages.settings.account.privacyDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 space-y-4">
{/* What leaves my computer */}
<SettingsSection title={t('privacy.whatLeavesComputer')}>
@@ -223,7 +222,7 @@ const PrivacyPanel = () => {
</div>
</div>
</div>
</div>
</PanelPage>
);
};
@@ -66,9 +66,9 @@ describe('ProfileEditorPage', () => {
it('create mode: name drives the slug and Create dispatches an upsert', async () => {
renderAt('/settings/profiles/new');
expect(screen.getByText('New profile')).toBeInTheDocument();
const name = screen.getByLabelText('Name');
expect(name).toBeInTheDocument();
fireEvent.change(name, { target: { value: 'My Research' } });
const id = screen.getByLabelText('ID') as HTMLInputElement;
expect(id.value).toBe('my-research'); // auto-slugged
@@ -19,8 +19,9 @@ import { useT } from '../../../lib/i18n/I18nContext';
import { selectAgentProfiles, upsertAgentProfile } from '../../../store/agentProfileSlice';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import type { AgentProfile } from '../../../types/agentProfile';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsRow,
SettingsSection,
@@ -89,7 +90,7 @@ const ProfileEditorPage = () => {
// fans out into multiple setters in an effect: the source is async Redux
// state that may arrive after mount, so a keyed remount / lazy initial state
// can't capture it. Mirrors the suppression used by other settings panels.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => {
if (isCreate) return;
if (!existing) {
@@ -166,19 +167,12 @@ const ProfileEditorPage = () => {
}
};
const title = isCreate
? t('settings.profiles.editor.createTitle')
: name || t('settings.profiles.editor.editTitle');
const breadcrumbs = [
{ label: t('nav.settings'), onClick: () => navigate('/settings') },
{ label: t('settings.profiles.title'), onClick: backToList },
];
return (
<div className="z-10 relative">
<SettingsHeader title={title} showBackButton onBack={backToList} breadcrumbs={breadcrumbs} />
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.profiles.menuDesc')}
leading={<SettingsBackButton onBack={backToList} />}>
<div className="p-4">
{notFound ? (
<div className="space-y-3">
@@ -404,7 +398,7 @@ const ProfileEditorPage = () => {
</div>
)}
</div>
</div>
</PanelPage>
);
};
@@ -19,8 +19,9 @@ import {
selectAgentProfiles,
} from '../../../store/agentProfileSlice';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsEmptyState, SettingsSection } from '../controls';
const ProfilesPanel = () => {
@@ -51,7 +52,6 @@ const ProfilesPanel = () => {
const remove = useCallback(
async (id: string) => {
// eslint-disable-next-line no-alert
if (!window.confirm(t('settings.profiles.deleteConfirm'))) return;
setActionError(null);
try {
@@ -63,27 +63,22 @@ const ProfilesPanel = () => {
[dispatch, t]
);
const breadcrumbs = [{ label: t('nav.settings'), onClick: () => navigate('/settings') }];
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.profiles.title')}
showBackButton
onBack={() => navigate('/settings')}
breadcrumbs={breadcrumbs}
action={
<Button
type="button"
variant="primary"
size="sm"
onClick={() => navigate('/settings/profiles/new')}>
<LuPlus className="h-4 w-4" />
{t('settings.profiles.new')}
</Button>
}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.profiles.menuDesc')}
leading={<SettingsBackButton onBack={() => navigate('/settings')} />}
action={
<Button
type="button"
variant="primary"
size="sm"
onClick={() => navigate('/settings/profiles/new')}>
<LuPlus className="h-4 w-4" />
{t('settings.profiles.new')}
</Button>
}>
<div className="space-y-4 p-4">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.profiles.subtitle')}
@@ -169,7 +164,7 @@ const ProfilesPanel = () => {
</SettingsSection>
)}
</div>
</div>
</PanelPage>
);
};
@@ -8,8 +8,9 @@ import {
MNEMONIC_GENERATE_WORD_COUNT,
validateMnemonicPhrase,
} from '../../../utils/cryptoKeys';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsCheckbox } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -19,7 +20,7 @@ const IMPORT_SLOTS_INITIAL = MNEMONIC_GENERATE_WORD_COUNT;
const RecoveryPhrasePanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const { snapshot, setEncryptionKey } = useCoreState();
const user = snapshot.currentUser;
@@ -203,14 +204,11 @@ const RecoveryPhrasePanel = () => {
const canSave = mode === 'generate' ? confirmed : isImportComplete;
return (
<div className="z-10 relative">
<SettingsHeader
title={t('mnemonic.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('pages.settings.account.recoveryPhraseDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div>
<div className="p-4">
{success ? (
@@ -493,7 +491,7 @@ const RecoveryPhrasePanel = () => {
)}
</div>
</div>
</div>
</PanelPage>
);
};
@@ -7,7 +7,8 @@ import {
openhumanUpdateSandboxSettings,
type SandboxBackendId,
} from '../../../utils/tauriCommands';
import SettingsHeader from '../components/SettingsHeader';
import PanelPage from '../../layout/PanelPage';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsBadge,
SettingsEmptyState,
@@ -31,7 +32,7 @@ const BACKEND_OPTIONS: SandboxBackendId[] = [
const SandboxSettingsPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [isLoading, setIsLoading] = useState(isTauri());
const [isSaving, setIsSaving] = useState(false);
@@ -136,47 +137,42 @@ const SandboxSettingsPanel = () => {
if (!isTauri()) {
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.sandbox.title')}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.sandbox.menuDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 pt-2">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.sandbox.desktopOnly')}
</p>
</div>
</div>
</PanelPage>
);
}
if (isLoading) {
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.sandbox.title')}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.sandbox.menuDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 pt-2">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.sandbox.loading')}
</p>
</div>
</div>
</PanelPage>
);
}
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.sandbox.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.sandbox.menuDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 pt-2 space-y-5">
{/* Status section */}
<SettingsSection title={t('settings.sandbox.status')}>
@@ -340,7 +336,7 @@ const SandboxSettingsPanel = () => {
savingLabel={t('settings.sandbox.saving')}
/>
</div>
</div>
</PanelPage>
);
};
@@ -4,9 +4,10 @@ import ScreenIntelligenceDebugPanel from '../../../components/intelligence/Scree
import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState';
import { useT } from '../../../lib/i18n/I18nContext';
import { isTauri, openhumanUpdateScreenIntelligenceSettings } from '../../../utils/tauriCommands';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import Input from '../../ui/Input';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsCheckbox,
SettingsEmptyState,
@@ -46,7 +47,7 @@ const DebugSection = ({
const ScreenAwarenessDebugPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const {
status,
lastError,
@@ -117,14 +118,11 @@ const ScreenAwarenessDebugPanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('screenAwareness.debugTitle')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.developerMenu.screenAwareness.desc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
{/* Advanced policy settings */}
<SettingsSection title={t('screenAwareness.debug.policyTitle')}>
@@ -296,7 +294,7 @@ const ScreenAwarenessDebugPanel = () => {
{/* Error notice */}
{lastError && <SettingsStatusLine saving={false} error={lastError} savingLabel="" />}
</div>
</div>
</PanelPage>
);
};
@@ -3,8 +3,9 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState';
import { useT } from '../../../lib/i18n/I18nContext';
import { isTauri, openhumanUpdateScreenIntelligenceSettings } from '../../../utils/tauriCommands';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsRow, SettingsSection, SettingsSelect, SettingsStatusLine } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import PermissionsSection from './screen-intelligence/PermissionsSection';
@@ -24,7 +25,7 @@ const formatRemaining = (remainingMs: number | null): string => {
const ScreenIntelligencePanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const {
status,
lastRestartSummary,
@@ -111,14 +112,11 @@ const ScreenIntelligencePanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.features.screenAwareness')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('pages.settings.features.screenAwarenessDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
{(status?.platform_supported ?? true) && (
<PermissionsSection
@@ -275,7 +273,7 @@ const ScreenIntelligencePanel = () => {
</div>
)}
</div>
</div>
</PanelPage>
);
};
@@ -10,9 +10,10 @@ import {
type SearchSettings,
type SearchSettingsUpdate,
} from '../../../utils/tauriCommands/config';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import Input from '../../ui/Input';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsStatusLine, SettingsTextArea } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -57,7 +58,7 @@ const normalizeAllowedHost = (raw: string): string =>
const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const { snapshot } = useCoreState();
const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
@@ -224,16 +225,12 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
};
return (
<div className="z-10 relative" data-testid="search-settings-panel">
{!embedded && (
<SettingsHeader
title={t('settings.search.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<PanelPage
className="z-10"
testId="search-settings-panel"
contentClassName=""
description={embedded ? undefined : t('settings.search.menuDesc')}
leading={embedded ? undefined : <SettingsBackButton onBack={navigateBack} />}>
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
<p className="text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{t('settings.search.description')}
@@ -462,7 +459,7 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
</>
)}
</div>
</div>
</PanelPage>
);
};
@@ -3,8 +3,9 @@ import { useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { decideKeyringConsent, retryKeyringProbe } from '../../../services/keyringApi';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsBadge, SettingsRow, SettingsSection, SettingsStatusLine } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -16,7 +17,7 @@ const MODE_BADGE_VARIANT: Record<string, 'success' | 'warning' | 'neutral' | 'da
};
const SecurityPanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const { snapshot } = useCoreState();
const { t } = useT();
const [isLoading, setIsLoading] = useState(false);
@@ -51,13 +52,11 @@ const SecurityPanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('keyring.settings.title')}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('pages.settings.account.securityDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 pt-2 space-y-5">
{/* Storage mode */}
<SettingsSection title={t('keyring.settings.storageMode')}>
@@ -146,7 +145,7 @@ const SecurityPanel = () => {
savingLabel={t('keyring.consent.retrying')}
/>
</div>
</div>
</PanelPage>
);
};
@@ -1,6 +1,7 @@
import { useT } from '../../../lib/i18n/I18nContext';
import IntelligenceTasksTab from '../../intelligence/IntelligenceTasksTab';
import SettingsHeader from '../components/SettingsHeader';
import PanelPage from '../../layout/PanelPage';
import SettingsBackButton from '../components/SettingsBackButton';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
/**
@@ -14,24 +15,22 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
*/
const TasksPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
return (
<div className="z-10 relative" data-testid="tasks-panel">
<SettingsHeader
title={t('memory.tab.tasks')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
testId="tasks-panel"
description={t('settings.developerMenu.tasks.desc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4">
<p className="mb-4 text-xs text-neutral-500 dark:text-neutral-400">
{t('memory.tab.tasksDescription')}
</p>
<IntelligenceTasksTab />
</div>
</div>
</PanelPage>
);
};
@@ -6,9 +6,10 @@ import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import { sanitizeError } from '../../../utils/sanitize';
import PanelPage from '../../layout/PanelPage';
import { CenteredLoadingState, ErrorBanner, InlineLoadingStatus, Spinner } from '../../ui';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsBadge, SettingsEmptyState, SettingsSection } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -18,7 +19,7 @@ const TeamInvitesPanel = () => {
const { t } = useT();
const { teamId } = useParams<{ teamId: string }>();
const location = useLocation();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const { snapshot, teams, teamInvitesById, refreshTeamInvites } = useCoreState();
const user = snapshot.currentUser;
@@ -118,14 +119,11 @@ const TeamInvitesPanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('invites.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('pages.settings.account.teamDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 pt-2 space-y-5">
{error && <ErrorBanner message={error} />}
@@ -344,7 +342,7 @@ const TeamInvitesPanel = () => {
</div>
)}
</div>
</div>
</PanelPage>
);
};
@@ -4,8 +4,9 @@ import { useParams } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import SettingsMenuItem from '../components/SettingsMenuItem';
import { SettingsSection, SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -13,7 +14,7 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const TeamManagementPanel = () => {
const { t } = useT();
const { teamId } = useParams<{ teamId: string }>();
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
const { navigateBack, navigateToSettings } = useSettingsNavigation();
const { teams, refreshTeams } = useCoreState();
const initialFetchAttemptedRef = useRef(false);
@@ -93,47 +94,40 @@ const TeamManagementPanel = () => {
if (!teamEntry) {
return (
<div className="z-10 relative">
<SettingsHeader
title={t('team.management')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('pages.settings.account.teamDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-neutral-500 dark:text-neutral-400">{t('team.notFound')}</p>
</div>
</div>
</PanelPage>
);
}
if (!isAdmin) {
return (
<div className="z-10 relative">
<SettingsHeader
title={t('team.management')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('pages.settings.account.teamDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-neutral-500 dark:text-neutral-400">{t('team.accessDenied')}</p>
</div>
</div>
</PanelPage>
);
}
const { team } = teamEntry;
return (
<div className="z-10 relative">
<SettingsHeader
title={t('team.manageTitle').replace('{name}', team.name)}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('pages.settings.account.teamDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 pt-2 space-y-5">
{/* Team Info */}
<SettingsSection>
@@ -346,7 +340,7 @@ const TeamManagementPanel = () => {
</div>
)}
</div>
</div>
</PanelPage>
);
};
@@ -7,9 +7,10 @@ import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import type { TeamMember, TeamRole } from '../../../types/team';
import { sanitizeError } from '../../../utils/sanitize';
import PanelPage from '../../layout/PanelPage';
import { CenteredLoadingState, ErrorBanner, InlineLoadingStatus } from '../../ui';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsBadge, SettingsEmptyState, SettingsSection, SettingsSelect } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -21,7 +22,7 @@ const TeamMembersPanel = () => {
const { t } = useT();
const { teamId } = useParams<{ teamId: string }>();
const location = useLocation();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const { snapshot, teams, teamMembersById, refreshTeamMembers } = useCoreState();
const user = snapshot.currentUser;
@@ -130,14 +131,11 @@ const TeamMembersPanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('team.members')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('pages.settings.account.teamDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 pt-2 space-y-5">
{error && <ErrorBanner message={error} />}
@@ -358,7 +356,7 @@ const TeamMembersPanel = () => {
</div>
)}
</div>
</div>
</PanelPage>
);
};
@@ -7,9 +7,10 @@ import { teamApi } from '../../../services/api/teamApi';
import { CoreRpcError } from '../../../services/coreRpcClient';
import type { TeamWithRole } from '../../../types/team';
import { sanitizeError } from '../../../utils/sanitize';
import PanelPage from '../../layout/PanelPage';
import { CenteredLoadingState, ErrorBanner } from '../../ui';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsBadge, SettingsSection, SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -17,7 +18,7 @@ const log = debug('core-rpc:error');
const TeamPanel = () => {
const { t } = useT();
const { navigateBack, navigateToTeamManagement, breadcrumbs } = useSettingsNavigation();
const { navigateBack, navigateToTeamManagement } = useSettingsNavigation();
const { snapshot, teams, refresh, refreshTeams } = useCoreState();
const user = snapshot.currentUser;
@@ -249,14 +250,11 @@ const TeamPanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.account.team')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('pages.settings.account.teamDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 pt-2 space-y-5">
{error && <ErrorBanner message={error} />}
@@ -367,7 +365,7 @@ const TeamPanel = () => {
</div>
)}
</div>
</div>
</PanelPage>
);
};
@@ -2,7 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { callCoreRpc } from '../../../services/coreRpcClient';
import SettingsHeader from '../components/SettingsHeader';
import PanelPage from '../../layout/PanelPage';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsStatusLine } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -45,7 +46,7 @@ type ToolPolicyDiagnostics = {
const ToolPolicyDiagnosticsPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [status, setStatus] = useState<
| { kind: 'loading' }
@@ -259,15 +260,13 @@ const ToolPolicyDiagnosticsPanel = () => {
}, [status, t]);
return (
<div className="z-10 relative">
<SettingsHeader
title={t('devOptions.diagnostics')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('devOptions.toolPolicyDiagnosticsDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
{body}
</div>
</PanelPage>
);
};
@@ -14,6 +14,7 @@ vi.mock('../../../lib/i18n/I18nContext', () => ({
t: (key: string) =>
({
'settings.features.tools': 'Tools',
'pages.settings.features.toolsDesc': 'Tools desc',
'settings.tools.chooseCapabilities': 'Choose capabilities',
'settings.tools.saveChanges': 'Save Changes',
'settings.tools.preferencesSaved': 'Preferences saved',
@@ -72,17 +73,16 @@ describe('<ToolsPanel />', () => {
);
});
it('renders SettingsHeader when embedded=false (line 110)', () => {
// Default embedded=false shows the header
it('renders the panel header description when embedded=false (line 110)', () => {
// Default embedded=false shows the header description
render(<ToolsPanel embedded={false} />);
expect(screen.getByText('Tools')).toBeInTheDocument();
expect(screen.getByText('Tools desc')).toBeInTheDocument();
});
it('does not render SettingsHeader when embedded=true (line 101-108 skipped)', () => {
// When embedded, the header section is not rendered
it('does not render the panel header when embedded=true (line 101-108 skipped)', () => {
// When embedded, the header description is not rendered
render(<ToolsPanel embedded={true} />);
// The header mock outputs a <h1> with the title — embedded should NOT show it
expect(screen.queryByRole('heading', { name: 'Tools' })).not.toBeInTheDocument();
expect(screen.queryByText('Tools desc')).not.toBeInTheDocument();
});
it('shows Save Changes button after toggling a tool (dirty state, line 145-155)', async () => {
@@ -10,8 +10,9 @@ import {
normalizeEnabledToolList,
TOOL_CATEGORIES,
} from '../../../utils/toolDefinitions';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsRow, SettingsSection, SettingsStatusLine, SettingsSwitch } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
@@ -23,7 +24,7 @@ interface ToolsPanelProps {
const ToolsPanel = ({ embedded = false }: ToolsPanelProps = {}) => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const { snapshot, setOnboardingTasks } = useCoreState();
const toolsByCategory = getToolsByCategory();
@@ -97,16 +98,11 @@ const ToolsPanel = ({ embedded = false }: ToolsPanelProps = {}) => {
};
return (
<div className="z-10 relative">
{!embedded && (
<SettingsHeader
title={t('settings.features.tools')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<PanelPage
className="z-10"
contentClassName=""
description={embedded ? undefined : t('pages.settings.features.toolsDesc')}
leading={embedded ? undefined : <SettingsBackButton onBack={navigateBack} />}>
<div className={embedded ? 'space-y-4' : 'p-4 pt-2 space-y-4'}>
<p className="text-neutral-500 dark:text-neutral-400 text-sm">
{t('settings.tools.chooseCapabilities')}
@@ -161,7 +157,7 @@ const ToolsPanel = ({ embedded = false }: ToolsPanelProps = {}) => {
savingLabel=""
/>
</div>
</div>
</PanelPage>
);
};
@@ -4,7 +4,8 @@ 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 PanelPage from '../../layout/PanelPage';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsStatusLine } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import { BackgroundLoopControls } from './AIPanel';
@@ -25,7 +26,7 @@ const hashToTab = (hash: string): TabId => (hash === '#background' ? 'background
*/
const UsagePanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const location = useLocation();
const navigate = useNavigate();
// The router is the single source of truth for the active tab.
@@ -35,47 +36,28 @@ const UsagePanel = () => {
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>
<PanelPage<TabId>
className="z-10"
description={t('settings.usage.menuDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}
tabsAriaLabel={t('settings.usage.title')}
tabsTestIdPrefix="usage-tab"
value={tab}
onChange={selectTab}
tabs={[
{
id: 'costs',
label: t('settings.costDashboard.title'),
content: <CostDashboardPanel embedded />,
},
{
id: 'background',
label: t('settings.heartbeat.title'),
content: <BackgroundActivityTab />,
},
]}
/>
);
};
@@ -10,8 +10,9 @@ import {
type VoiceServerStatus,
type VoiceStatus,
} from '../../../utils/tauriCommands';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsNumberField,
SettingsRow,
@@ -23,7 +24,7 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const VoiceDebugPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const [settings, setSettings] = useState<VoiceServerSettings | null>(null);
const [savedSettings, setSavedSettings] = useState<VoiceServerSettings | null>(null);
const [serverStatus, setServerStatus] = useState<VoiceServerStatus | null>(null);
@@ -123,14 +124,11 @@ const VoiceDebugPanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('voice.debugTitle')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.developerMenu.voiceDebug.desc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 pt-2 space-y-5">
{/* Runtime status section */}
<SettingsSection
@@ -268,7 +266,7 @@ const VoiceDebugPanel = () => {
</div>
</SettingsSection>
</div>
</div>
</PanelPage>
);
};
@@ -28,8 +28,9 @@ import {
type VoiceServerSettings,
type VoiceStatus,
} from '../../../utils/tauriCommands';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsRow,
SettingsSection,
@@ -98,7 +99,7 @@ interface VoicePanelProps {
const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
const { t } = useT();
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
const { navigateBack, navigateToSettings } = useSettingsNavigation();
const [settings, setSettings] = useState<VoiceServerSettings | null>(null);
const [savedSettings, setSavedSettings] = useState<VoiceServerSettings | null>(null);
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus | null>(null);
@@ -486,16 +487,11 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
const piperReady = piperInstall?.state === 'installed';
return (
<div className="z-10 relative">
{!embedded && (
<SettingsHeader
title={t('voice.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
)}
<PanelPage
className="z-10"
contentClassName=""
description={embedded ? undefined : t('pages.settings.ai.voiceDesc')}
leading={embedded ? undefined : <SettingsBackButton onBack={navigateBack} />}>
<div className={embedded ? 'space-y-4' : 'p-4 space-y-4'}>
{/* ─── Always-on listening (Phase 2) ──────────────────────────── */}
{settings && (
@@ -1303,7 +1299,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
savingLabel={t('common.loading')}
/>
</div>
</div>
</PanelPage>
);
};
@@ -13,8 +13,9 @@ import {
fetchWalletStatus,
type WalletChain,
} from '../../../services/walletApi';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { SettingsEmptyState, SettingsSection } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import ReceiveModal from './wallet/ReceiveModal';
@@ -249,7 +250,7 @@ const ChainPlaceholderRow = ({
const WalletBalancesPanel = () => {
const { t } = useT();
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
const { navigateBack, navigateToSettings } = useSettingsNavigation();
const [balances, setBalances] = useState<BalanceInfo[] | null>(null);
const [loading, setLoading] = useState(false);
@@ -452,38 +453,35 @@ const WalletBalancesPanel = () => {
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('walletBalances.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
action={
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void loadBalances()}
disabled={loading}
aria-label={t('walletBalances.refresh')}
className="gap-1.5 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300">
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
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>
{t('walletBalances.refresh')}
</Button>
}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('pages.settings.account.walletBalancesDesc')}
leading={<SettingsBackButton onBack={navigateBack} />}
action={
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void loadBalances()}
disabled={loading}
aria-label={t('walletBalances.refresh')}
className="gap-1.5 text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300">
<svg
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
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>
{t('walletBalances.refresh')}
</Button>
}>
<div className="mx-4 mb-4">
<SettingsSection>{renderContent()}</SettingsSection>
</div>
@@ -498,7 +496,7 @@ const WalletBalancesPanel = () => {
{receiveTarget && (
<ReceiveModal balance={receiveTarget} onClose={() => setReceiveTarget(null)} />
)}
</div>
</PanelPage>
);
};
@@ -16,8 +16,9 @@ import {
type WebhookDebugLogEntry,
type WebhookDebugRegistration,
} from '../../../utils/tauriCommands';
import PanelPage from '../../layout/PanelPage';
import Button from '../../ui/Button';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import {
SettingsBadge,
SettingsEmptyState,
@@ -52,7 +53,7 @@ function prettyJson(value: unknown): string {
const WebhooksDebugPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
const backendUrl = useBackendUrl();
const [registrations, setRegistrations] = useState<WebhookDebugRegistration[]>([]);
const [logs, setLogs] = useState<WebhookDebugLogEntry[]>([]);
@@ -162,14 +163,12 @@ const WebhooksDebugPanel = () => {
}, [loadData, t]);
return (
<div className="z-10 relative" data-testid="webhooks-debug-panel">
<SettingsHeader
title={t('webhooks.debugTitle')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
testId="webhooks-debug-panel"
description={t('settings.developerMenu.webhooks.desc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="p-4 space-y-4">
{/* Status bar */}
<div className="flex flex-wrap items-center gap-2">
@@ -342,7 +341,7 @@ const WebhooksDebugPanel = () => {
</div>
</SettingsSection>
</div>
</div>
</PanelPage>
);
};
@@ -8,21 +8,17 @@ vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string)
vi.mock('../../skills/WorkflowRunnerBody', () => ({
default: () => <div data-testid="skills-runner-body" />,
}));
vi.mock('../components/SettingsHeader', () => ({
default: ({ title }: { title: string }) => <div data-testid="settings-header">{title}</div>,
}));
vi.mock('../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
}));
describe('WorkflowRunnerPanel', () => {
it('renders the settings header and runner body', () => {
it('renders the runner body', () => {
render(
<MemoryRouter>
<WorkflowRunnerPanel />
</MemoryRouter>
);
expect(screen.getByTestId('settings-header')).toBeInTheDocument();
expect(screen.getByTestId('skills-runner-body')).toBeInTheDocument();
});
});
@@ -5,26 +5,25 @@
// `app/src/components/skills/WorkflowRunnerBody.tsx`, shared with the
// top-level /skills page's "Runners" tab.
import { useT } from '../../../lib/i18n/I18nContext';
import PanelPage from '../../layout/PanelPage';
import WorkflowRunnerBody from '../../skills/WorkflowRunnerBody';
import SettingsHeader from '../components/SettingsHeader';
import SettingsBackButton from '../components/SettingsBackButton';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const WorkflowRunnerPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { navigateBack } = useSettingsNavigation();
return (
<div className="z-10 relative flex flex-col h-full">
<SettingsHeader
title={t('settings.developerMenu.skillsRunner.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<PanelPage
className="z-10"
contentClassName=""
description={t('settings.developerMenu.skillsRunner.desc')}
leading={<SettingsBackButton onBack={navigateBack} />}>
<div className="flex-1 overflow-y-auto p-6">
<WorkflowRunnerBody />
</div>
</div>
</PanelPage>
);
};
@@ -40,9 +40,8 @@ describe('AgentChatPanel', () => {
it('renders the panel header and empty conversation area', async () => {
renderWithProviders(<AgentChatPanel />);
expect(await screen.findByText('Agent Chat')).toBeInTheDocument();
// Empty state label shown when no messages
expect(screen.getByText(/start a conversation/i)).toBeInTheDocument();
expect(await screen.findByText(/start a conversation/i)).toBeInTheDocument();
});
it('shows model and temperature input fields', async () => {
@@ -120,7 +120,7 @@ describe('AutocompletePanel (simplified)', () => {
it('shows user-facing settings and can save style preset changes', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
await screen.findByText('Style Preset');
// Verify user-facing controls are present
expect(screen.getByText('Enabled')).toBeInTheDocument();
@@ -150,7 +150,7 @@ describe('AutocompletePanel (simplified)', () => {
it('can start and stop the autocomplete runtime', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
await screen.findByText('Style Preset');
// Wait for status to load
await waitFor(() => {
@@ -183,7 +183,7 @@ describe('AutocompletePanel (simplified)', () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
await screen.findByText('Style Preset');
// Wait for config to load
await waitFor(() => {
@@ -211,7 +211,7 @@ describe('AutocompletePanel (simplified)', () => {
it('shows the Advanced settings link', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
await screen.findByText('Style Preset');
expect(screen.getByText('Advanced settings')).toBeInTheDocument();
});
@@ -222,7 +222,7 @@ describe('AutocompletePanel (simplified)', () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
await screen.findByText('Style Preset');
// SettingsNumberField wraps the input in a div; find the inner spinbutton
const debounceWrapper = await screen.findByTestId('autocomplete-debounce-ms');
@@ -254,7 +254,7 @@ describe('AutocompletePanel (simplified)', () => {
it('allows clearing a tuning field mid-edit and clamps to safe minimums at save', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
await screen.findByText('Style Preset');
const maxCharsWrapper = await screen.findByTestId('autocomplete-max-chars');
const debounceWrapper = screen.getByTestId('autocomplete-debounce-ms');
@@ -285,7 +285,7 @@ describe('AutocompletePanel (simplified)', () => {
runtime.config.disabled_apps = ['Slack', 'Zoom'];
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
await screen.findByText('Style Preset');
const textarea = (await screen.findByRole('textbox', {
name: /disabled apps/i,
@@ -311,7 +311,7 @@ describe('AutocompletePanel (simplified)', () => {
it('committing a debounce value via Enter triggers saveConfig', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
await screen.findByText('Style Preset');
const debounceWrapper = await screen.findByTestId('autocomplete-debounce-ms');
const debounce = within(debounceWrapper).getByRole('spinbutton') as HTMLInputElement;
@@ -330,7 +330,7 @@ describe('AutocompletePanel (simplified)', () => {
it('committing a max-chars value triggers saveConfig', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
await screen.findByText('Style Preset');
const maxCharsWrapper = await screen.findByTestId('autocomplete-max-chars');
const maxChars = within(maxCharsWrapper).getByRole('spinbutton') as HTMLInputElement;
@@ -347,7 +347,7 @@ describe('AutocompletePanel (simplified)', () => {
it('committing an overlay-ttl value triggers saveConfig', async () => {
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
await screen.findByText('Style Preset');
const overlayWrapper = await screen.findByTestId('autocomplete-overlay-ttl-ms');
const overlayTtl = within(overlayWrapper).getByRole('spinbutton') as HTMLInputElement;
@@ -371,7 +371,7 @@ describe('AutocompletePanel (simplified)', () => {
});
renderWithProviders(<AutocompletePanel />, { initialEntries: ['/settings/autocomplete'] });
await screen.findByText('Autocomplete');
await screen.findByText('Style Preset');
await waitFor(() => expect(screen.getByText('Running: No')).toBeInTheDocument());
@@ -60,7 +60,8 @@ describe('ComposioPanel', () => {
await waitFor(() => {
expect(screen.queryByText('Loading…')).toBeNull();
});
expect(screen.getByText('Composio')).toBeInTheDocument();
// Titles were dropped in favour of the panel description.
expect(screen.getByText(/Bring your own Composio API key/)).toBeInTheDocument();
});
test('defaults to Backend mode when getMode returns backend', async () => {
@@ -50,7 +50,6 @@ describe('ComposioTriagePanel', () => {
expect(screen.queryByText('Loading…')).toBeNull();
});
expect(screen.getByText('Integration Triggers')).toBeInTheDocument();
expect(screen.getByLabelText('Disable AI triage for all triggers')).toBeInTheDocument();
expect(screen.getByPlaceholderText('gmail, slack, ...')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
@@ -45,10 +45,6 @@ vi.mock('../../hooks/useSettingsNavigation', () => ({
}),
}));
vi.mock('../../components/SettingsHeader', () => ({
default: ({ title }: { title: string }) => <div data-testid="settings-header">{title}</div>,
}));
// Import once — DevWorkflowPanel state is managed via API mocks and
// cron RPC, not module-level vars, so a single import is sufficient.
async function importPanel() {
@@ -116,7 +112,7 @@ describe('DevWorkflowPanel', () => {
renderWithProviders(<Panel />);
// Header is rendered synchronously
expect(screen.getByTestId('settings-header')).toBeInTheDocument();
expect(screen.getByTestId('dev-workflow-panel')).toBeInTheDocument();
// Wait for repos to load
await waitFor(() => {
@@ -321,7 +317,7 @@ describe('DevWorkflowPanel', () => {
renderWithProviders(<Panel />);
// Header always renders
expect(screen.getByTestId('settings-header')).toBeInTheDocument();
expect(screen.getByTestId('dev-workflow-panel')).toBeInTheDocument();
// Error state shown
await waitFor(() => {
@@ -519,7 +515,7 @@ describe('DevWorkflowPanel', () => {
renderWithProviders(<Panel />);
// Panel should still render despite cronList failure
expect(screen.getByTestId('settings-header')).toBeInTheDocument();
expect(screen.getByTestId('dev-workflow-panel')).toBeInTheDocument();
// Repos should still load
await waitFor(() => {
@@ -1,9 +1,16 @@
import { fireEvent, screen } from '@testing-library/react';
import { useLocation } from 'react-router-dom';
import { describe, expect, test, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
import IntegrationsPanel from '../IntegrationsPanel';
// Surfaces the current router location so we can assert legacy-hash redirects.
const LocationProbe = () => {
const location = useLocation();
return <div data-testid="location-probe">{`${location.pathname}${location.search}`}</div>;
};
// 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', () => ({
@@ -12,12 +19,6 @@ vi.mock('../TaskSourcesPanel', () => ({
),
}));
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)} />
@@ -41,23 +42,9 @@ describe('IntegrationsPanel', () => {
'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'],
@@ -70,12 +57,21 @@ describe('IntegrationsPanel', () => {
expect(screen.getByTestId('stub-webhooks')).toHaveAttribute('data-embedded', 'true');
});
test('legacy #composio hash redirects to Connections → API keys', () => {
renderWithProviders(
<>
<IntegrationsPanel />
<LocationProbe />
</>,
{ initialEntries: ['/settings/integrations#composio'] }
);
expect(screen.getByTestId('location-probe')).toHaveTextContent('/connections?tab=composio-key');
});
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');
@@ -21,7 +21,7 @@ vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
}));
vi.mock('../components/SettingsHeader', () => ({ default: () => null }));
vi.mock('../../components/SettingsBackButton', () => ({ default: () => null }));
vi.mock('../../../intelligence/MemoryTextWithEntities', () => ({
MemoryTextWithEntities: ({ text }: { text: string }) => (
@@ -33,8 +33,7 @@ describe('TasksPanel', () => {
it('renders the panel shell, title, description and the task board', () => {
renderPanel();
expect(screen.getByTestId('tasks-panel')).toBeInTheDocument();
// SettingsHeader title + the descriptive blurb both come from i18n keys.
expect(screen.getAllByText('memory.tab.tasks').length).toBeGreaterThan(0);
// The descriptive blurb comes from an i18n key.
expect(screen.getByText('memory.tab.tasksDescription')).toBeInTheDocument();
// The re-homed task board is mounted unchanged.
expect(screen.getByTestId('intelligence-tasks-tab')).toBeInTheDocument();
@@ -57,6 +57,7 @@ vi.mock('../../hooks/useSettingsNavigation', () => ({
}));
vi.mock('../../components/SettingsHeader', () => ({ default: () => null }));
vi.mock('../../components/SettingsBackButton', () => ({ default: () => null }));
vi.mock('react-router-dom', () => ({
useParams: () => ({ teamId: 'team-u1' }),
@@ -65,6 +65,7 @@ vi.mock('../../hooks/useSettingsNavigation', () => ({
vi.mock('../../components/SettingsHeader', () => ({
default: ({ title }: { title: string }) => <h1 data-testid="settings-header">{title}</h1>,
}));
vi.mock('../../components/SettingsBackButton', () => ({ default: () => null }));
const mockUpdateTeam = vi.fn();
const mockDeleteTeam = vi.fn();
@@ -61,6 +61,7 @@ vi.mock('../../hooks/useSettingsNavigation', () => ({
}));
vi.mock('../../components/SettingsHeader', () => ({ default: () => null }));
vi.mock('../../components/SettingsBackButton', () => ({ default: () => null }));
vi.mock('react-router-dom', () => ({
useParams: () => ({ teamId: 'team-1' }),
+3
View File
@@ -392,6 +392,9 @@ const messages: TranslationMap = {
'connections.tabs.skills': 'المهارات',
'connections.tabs.meetings': 'الاجتماعات',
'connections.groups.integrations': 'التكاملات',
'connections.tabs.oauth': 'OAuth',
'connections.tabs.composioKey': 'Composio',
'connections.groups.apiKeys': 'مفاتيح API',
'connections.groups.intelligence': 'الذكاء',
'memory.title': 'الذاكرة',
'memory.search': 'البحث في الذكريات...',
+3
View File
@@ -397,6 +397,9 @@ const messages: TranslationMap = {
'connections.tabs.skills': 'দক্ষতা',
'connections.tabs.meetings': 'মিটিং',
'connections.groups.integrations': 'ইন্টিগ্রেশন',
'connections.tabs.oauth': 'OAuth',
'connections.tabs.composioKey': 'Composio',
'connections.groups.apiKeys': 'API কী',
'connections.groups.intelligence': 'বুদ্ধিমত্তা',
'memory.title': 'মেমোরি',
'memory.search': 'মেমোরি খুঁজুন...',
+3
View File
@@ -409,6 +409,9 @@ const messages: TranslationMap = {
'connections.tabs.skills': 'Fähigkeiten',
'connections.tabs.meetings': 'Meetings',
'connections.groups.integrations': 'Integrationen',
'connections.tabs.oauth': 'OAuth',
'connections.tabs.composioKey': 'Composio',
'connections.groups.apiKeys': 'API-Schlüssel',
'connections.groups.intelligence': 'Intelligenz',
'memory.title': 'Erinnerung',
'memory.search': 'Erinnerungen suchen...',
+3
View File
@@ -432,12 +432,15 @@ const en: TranslationMap = {
'skills.tabs.mcp': 'MCP Servers',
// Connections page tabs (Phase 2 rename)
'connections.tabs.composio': 'Composio',
'connections.tabs.oauth': 'OAuth',
'connections.tabs.composioKey': 'Composio',
'connections.tabs.channels': 'Channels',
'connections.tabs.mcp': 'MCP Servers',
'connections.tabs.skills': 'Skills',
'connections.tabs.meetings': 'Meetings',
'connections.groups.integrations': 'Integrations',
'connections.groups.intelligence': 'Intelligence',
'connections.groups.apiKeys': 'API keys',
// Intelligence / Memory
'memory.title': 'Memory',
'memory.search': 'Search memories...',
+3
View File
@@ -410,6 +410,9 @@ const messages: TranslationMap = {
'connections.tabs.skills': 'Habilidades',
'connections.tabs.meetings': 'Reuniones',
'connections.groups.integrations': 'Integraciones',
'connections.tabs.oauth': 'OAuth',
'connections.tabs.composioKey': 'Composio',
'connections.groups.apiKeys': 'Claves de API',
'connections.groups.intelligence': 'Inteligencia',
'memory.title': 'Memoria',
'memory.search': 'Buscar recuerdos...',
+3
View File
@@ -410,6 +410,9 @@ const messages: TranslationMap = {
'connections.tabs.skills': 'Compétences',
'connections.tabs.meetings': 'Réunions',
'connections.groups.integrations': 'Intégrations',
'connections.tabs.oauth': 'OAuth',
'connections.tabs.composioKey': 'Composio',
'connections.groups.apiKeys': 'Clés API',
'connections.groups.intelligence': 'Intelligence',
'memory.title': 'Mémoire',
'memory.search': 'Rechercher dans la mémoire…',
+3
View File
@@ -396,6 +396,9 @@ const messages: TranslationMap = {
'connections.tabs.skills': 'कौशल',
'connections.tabs.meetings': 'मीटिंग',
'connections.groups.integrations': 'इंटीग्रेशन',
'connections.tabs.oauth': 'OAuth',
'connections.tabs.composioKey': 'Composio',
'connections.groups.apiKeys': 'API कुंजियाँ',
'connections.groups.intelligence': 'इंटेलिजेंस',
'memory.title': 'मेमोरी',
'memory.search': 'मेमोरी सर्च करें...',
+3
View File
@@ -400,6 +400,9 @@ const messages: TranslationMap = {
'connections.tabs.skills': 'Keterampilan',
'connections.tabs.meetings': 'Rapat',
'connections.groups.integrations': 'Integrasi',
'connections.tabs.oauth': 'OAuth',
'connections.tabs.composioKey': 'Composio',
'connections.groups.apiKeys': 'Kunci API',
'connections.groups.intelligence': 'Intelijen',
'memory.title': 'Memori',
'memory.search': 'Cari memori...',
+3
View File
@@ -406,6 +406,9 @@ const messages: TranslationMap = {
'connections.tabs.skills': 'Competenze',
'connections.tabs.meetings': 'Riunioni',
'connections.groups.integrations': 'Integrazioni',
'connections.tabs.oauth': 'OAuth',
'connections.tabs.composioKey': 'Composio',
'connections.groups.apiKeys': 'Chiavi API',
'connections.groups.intelligence': 'Intelligenza',
'memory.title': 'Memoria',
'memory.search': 'Cerca memorie...',
+3
View File
@@ -397,6 +397,9 @@ const messages: TranslationMap = {
'connections.tabs.skills': '스킬',
'connections.tabs.meetings': '미팅',
'connections.groups.integrations': '통합',
'connections.tabs.oauth': 'OAuth',
'connections.tabs.composioKey': 'Composio',
'connections.groups.apiKeys': 'API 키',
'connections.groups.intelligence': '인텔리전스',
'memory.title': '메모리',
'memory.search': '메모리 검색...',
+3
View File
@@ -402,6 +402,9 @@ const messages: TranslationMap = {
'connections.tabs.skills': 'Umiejętności',
'connections.tabs.meetings': 'Spotkania',
'connections.groups.integrations': 'Integracje',
'connections.tabs.oauth': 'OAuth',
'connections.tabs.composioKey': 'Composio',
'connections.groups.apiKeys': 'Klucze API',
'connections.groups.intelligence': 'Inteligencja',
'memory.title': 'Pamięć',
'memory.search': 'Szukaj w pamięci...',
+3
View File
@@ -409,6 +409,9 @@ const messages: TranslationMap = {
'connections.tabs.skills': 'Habilidades',
'connections.tabs.meetings': 'Reuniões',
'connections.groups.integrations': 'Integrações',
'connections.tabs.oauth': 'OAuth',
'connections.tabs.composioKey': 'Composio',
'connections.groups.apiKeys': 'Chaves de API',
'connections.groups.intelligence': 'Inteligência',
'memory.title': 'Memória',
'memory.search': 'Pesquisar memórias...',
+3
View File
@@ -402,6 +402,9 @@ const messages: TranslationMap = {
'connections.tabs.skills': 'Навыки',
'connections.tabs.meetings': 'Встречи',
'connections.groups.integrations': 'Интеграции',
'connections.tabs.oauth': 'OAuth',
'connections.tabs.composioKey': 'Composio',
'connections.groups.apiKeys': 'API-ключи',
'connections.groups.intelligence': 'Интеллект',
'memory.title': 'Память',
'memory.search': 'Поиск воспоминаний...',
+3
View File
@@ -380,6 +380,9 @@ const messages: TranslationMap = {
'connections.tabs.skills': '技能',
'connections.tabs.meetings': '会议',
'connections.groups.integrations': '集成',
'connections.tabs.oauth': 'OAuth',
'connections.tabs.composioKey': 'Composio',
'connections.groups.apiKeys': 'API 密钥',
'connections.groups.intelligence': '智能',
'memory.title': '记忆',
'memory.search': '搜索记忆...',
+76 -74
View File
@@ -14,6 +14,7 @@ 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 PanelPage from '../components/layout/PanelPage';
import TwoPanelLayout from '../components/layout/TwoPanelLayout';
import TwoPaneNav from '../components/layout/TwoPaneNav';
import { SettingsLayoutProvider } from '../components/settings/layout/SettingsLayoutContext';
@@ -147,7 +148,7 @@ export default function Brain() {
defaultSidebarWidth={210}
minSidebarWidth={170}
maxSidebarWidth={320}
showDividerHandle={false}
seamless
sidebar={
<TwoPaneNav
ariaLabel={t('nav.brain')}
@@ -235,85 +236,86 @@ export default function Brain() {
}
/>
}>
<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}
/>
{graph ? (
<MemoryGraph
nodes={graph.nodes}
edges={graph.edges}
{/* Knowledge & Memory panels relocated from Settings are themselves
PanelPage panels (description, no title; the back button hides
because the Brain sidebar owns navigation here), so they fill the
content pane and own their own scroll directly. */}
{KNOWLEDGE_TABS.has(activeTab) ? (
<SettingsLayoutProvider value={{ inTwoPaneShell: true }}>
{/* 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>
) : (
// Bespoke tabs share the standard scaffold: a single scrolling body,
// all custom controls live inside it.
<PanelPage contentClassName="p-4">
<div className="mx-auto max-w-3xl space-y-5">
{activeTab === 'graph' && (
<div className="space-y-5 animate-fade-up">
<MemoryControls
mode={mode}
emptyHint={t('brain.empty')}
onModeChange={setMode}
onRefresh={refresh}
onToast={addToast}
contentRootAbs={graph?.content_root_abs}
/>
) : error ? (
<div
className={`${cardClass} text-sm text-coral-600 dark:text-coral-400`}
role="alert">
{t('brain.error')}
{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>
)}
{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>
) : null}
</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}
/>
{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>
)}
</div>
</PanelPage>
)}
</TwoPanelLayout>
<ToastContainer notifications={toasts} onRemove={removeToast} />
+13 -7
View File
@@ -54,11 +54,13 @@ import WebhooksDebugPanel from '../components/settings/panels/WebhooksDebugPanel
import WorkflowRunnerPanel from '../components/settings/panels/WorkflowRunnerPanel';
const WrappedSettingsPage = ({ children }: { children: ReactNode }) => {
// The surrounding two-pane card (bg / border / rounding) is 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>;
// The surrounding two-pane card (bg / border / rounding) is provided by
// SettingsLayout's content pane, so panels sit directly on it. This wrapper
// fills the bounded Outlet area and is the page's single vertical scroll
// region: PanelScaffold-based panels are `h-full` and own their own internal
// scroll (so this never scrolls for them), while legacy panels that overflow
// scroll here. Either way there's exactly one scrollbar.
return <div className="h-full min-h-0 overflow-y-auto">{children}</div>;
};
/**
@@ -193,7 +195,11 @@ const Settings = () => {
path="notifications-hub"
element={<Navigate to="/settings/notifications" replace />}
/>
<Route path="composio" element={<Navigate to="/settings/integrations" replace />} />
{/* Composio (API key + routing) moved to Connections → API keys. */}
<Route
path="composio"
element={<Navigate to="/connections?tab=composio-key" replace />}
/>
{/* Merged Usage & Limits page */}
<Route path="heartbeat" element={<Navigate to="/settings/usage#background" replace />} />
<Route
@@ -210,7 +216,7 @@ const Settings = () => {
<Route path="task-sources" element={<Navigate to="/settings/integrations" replace />} />
<Route
path="composio-routing"
element={<Navigate to="/settings/integrations#composio" replace />}
element={<Navigate to="/connections?tab=composio-key" replace />}
/>
<Route
path="webhooks-triggers"
+202 -194
View File
@@ -11,10 +11,12 @@ import {
} from '../components/composio/toolkitMeta';
import EmptyStateCard from '../components/EmptyStateCard';
import { ToastContainer } from '../components/intelligence/Toast';
import PanelPage from '../components/layout/PanelPage';
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 ComposioPanel from '../components/settings/panels/ComposioPanel';
import EmbeddingsPanel from '../components/settings/panels/EmbeddingsPanel';
import SearchPanel from '../components/settings/panels/SearchPanel';
import VoicePanel from '../components/settings/panels/VoicePanel';
@@ -378,14 +380,16 @@ type ConnectionsTab =
| 'llm'
| 'voice'
| 'embeddings'
| 'search';
| 'search'
| 'composio-key';
/** Tabs that render a relocated settings panel (Intelligence group). */
/** Tabs that render a relocated settings panel (the "API keys" group). */
const INTELLIGENCE_TABS: ReadonlySet<ConnectionsTab> = new Set<ConnectionsTab>([
'llm',
'voice',
'embeddings',
'search',
'composio-key',
]);
export default function Skills() {
@@ -410,7 +414,8 @@ export default function Skills() {
raw === 'llm' ||
raw === 'voice' ||
raw === 'embeddings' ||
raw === 'search'
raw === 'search' ||
raw === 'composio-key'
)
return raw;
// Legacy back-compat aliases
@@ -817,7 +822,7 @@ export default function Skills() {
defaultSidebarWidth={210}
minSidebarWidth={170}
maxSidebarWidth={320}
showDividerHandle={false}
seamless
sidebar={
<TwoPaneNav
ariaLabel={t('nav.connections')}
@@ -834,7 +839,7 @@ export default function Skills() {
items: [
{
value: 'composio',
label: t('connections.tabs.composio'),
label: t('connections.tabs.oauth'),
icon: navIcon('M13 10V3L4 14h7v7l9-11h-7z'),
},
{
@@ -868,7 +873,7 @@ export default function Skills() {
],
},
{
label: t('connections.groups.intelligence'),
label: t('connections.groups.apiKeys'),
items: [
{
value: 'llm',
@@ -877,6 +882,13 @@ export default function Skills() {
'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: 'composio-key',
label: t('connections.tabs.composioKey'),
icon: navIcon(
'M15 7a2 2 0 012 2m4-2a6 6 0 01-7.743 5.743L11 14H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z'
),
},
{
value: 'voice',
label: t('pages.settings.ai.voice'),
@@ -901,9 +913,24 @@ export default function Skills() {
]}
/>
}>
<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">
{/* Intelligence panels relocated from Settings are themselves PanelPage
panels (description, no title; the back button hides because the
Connections sidebar owns navigation), so they fill the content pane
and own their scroll directly. */}
{INTELLIGENCE_TABS.has(activeTab) ? (
<SettingsLayoutProvider value={{ inTwoPaneShell: true }}>
{activeTab === 'llm' && <AIPanel />}
{activeTab === 'voice' && <VoicePanel />}
{activeTab === 'embeddings' && <EmbeddingsPanel />}
{activeTab === 'search' && <SearchPanel />}
{activeTab === 'composio-key' && <ComposioPanel />}
</SettingsLayoutProvider>
) : (
<PanelPage
description={activeTab === 'composio' ? t('skills.integrationsSubtitle') : undefined}
contentClassName="p-4">
<div className="mx-auto w-full max-w-3xl space-y-4">
{/* <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">
Skills
@@ -929,202 +956,183 @@ export default function Skills() {
</div>
</div> */}
{composioError && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-3 shadow-soft">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-amber-900">
{t('skills.composio.staleStatusTitle')}
</h2>
<p className="mt-1 text-xs leading-relaxed text-amber-800">{composioError}</p>
{composioError && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-3 shadow-soft">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-amber-900">
{t('skills.composio.staleStatusTitle')}
</h2>
<p className="mt-1 text-xs leading-relaxed text-amber-800">{composioError}</p>
</div>
<button
type="button"
onClick={() => void refreshComposio()}
className="flex-shrink-0 rounded-lg border border-amber-300 dark:border-amber-500/40 bg-white dark:bg-neutral-900 px-3 py-1.5 text-[11px] font-medium text-amber-800 dark:text-amber-300 transition-colors hover:bg-amber-100 dark:hover:bg-amber-500/10">
{t('common.retry')}
</button>
</div>
<button
type="button"
onClick={() => void refreshComposio()}
className="flex-shrink-0 rounded-lg border border-amber-300 dark:border-amber-500/40 bg-white dark:bg-neutral-900 px-3 py-1.5 text-[11px] font-medium text-amber-800 dark:text-amber-300 transition-colors hover:bg-amber-100 dark:hover:bg-amber-500/10">
{t('common.retry')}
</button>
</div>
</div>
)}
)}
{
<>
{activeTab === 'channels' && channelsGroup && (
<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">
<h2
className="flex items-center gap-2 text-sm font-semibold text-stone-900 dark:text-neutral-100"
data-walkthrough="skills-channels">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-stone-100 dark:bg-neutral-800">
<SkillCategoryIcon
category="Channels"
className={skillCategoryHeadingClassName('Channels')}
/>
</span>
{t('skills.channels')}
</h2>
<p className="mt-0.5 text-[11px] leading-relaxed text-stone-500 dark:text-neutral-400">
{t('channels.defaultMessaging')}
</p>
</div>
<div
className="grid gap-2 sm:gap-3"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))' }}>
{channelsGroup.items.map(item => (
<div key={item.id} data-testid={`skill-row-${item.id}`}>
<ChannelTile
def={item.channelDef!}
status={item.channelStatus!}
icon={item.icon}
testId={`skill-install-${item.id}`}
onOpen={() => setChannelModalDef(item.channelDef!)}
/>
{
<>
{activeTab === 'channels' && channelsGroup && (
<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">
<h2
className="flex items-center gap-2 text-sm font-semibold text-stone-900 dark:text-neutral-100"
data-walkthrough="skills-channels">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-stone-100 dark:bg-neutral-800">
<SkillCategoryIcon
category="Channels"
className={skillCategoryHeadingClassName('Channels')}
/>
</span>
{t('skills.channels')}
</h2>
<p className="mt-0.5 text-[11px] leading-relaxed text-stone-500 dark:text-neutral-400">
{t('channels.defaultMessaging')}
</p>
</div>
<div
className="grid gap-2 sm:gap-3"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))' }}>
{channelsGroup.items.map(item => (
<div key={item.id} data-testid={`skill-row-${item.id}`}>
<ChannelTile
def={item.channelDef!}
status={item.channelStatus!}
icon={item.icon}
testId={`skill-install-${item.id}`}
onOpen={() => setChannelModalDef(item.channelDef!)}
/>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-stone-100 dark:border-neutral-800">
<div className="text-[10px] font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400 mb-2">
{t('channels.defaultMessaging')}
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-stone-100 dark:border-neutral-800">
<div className="text-[10px] font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400 mb-2">
{t('channels.defaultMessaging')}
</div>
<div className="grid grid-cols-2 gap-2">
{channelDefs.map(def => {
const channelId = def.id as ChannelType;
const selected = channelConnections.defaultMessagingChannel === channelId;
return (
<button
key={channelId}
type="button"
onClick={() => void handleSetDefaultChannel(channelId)}
disabled={defaultChannelBusy !== null}
className={`rounded-lg border px-3 py-2 text-xs font-medium transition-colors ${
selected
? 'border-primary-500/60 bg-primary-50 dark:bg-primary-500/10 text-primary-600 dark:text-primary-300'
: 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700'
}`}>
{def.display_name}
</button>
);
})}
</div>
</div>
</div>
)}
{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">
<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/integrations#composio')}
/>
)}
{!showLocalComposioApiKeyBanner && (
<div className="space-y-3 px-1 pb-3">
<SkillSearchBar value={searchQuery} onChange={setSearchQuery} />
<SkillCategoryFilter
categories={availableCategories}
selected={selectedCategory}
onChange={setSelectedCategory}
/>
</div>
)}
{!showLocalComposioApiKeyBanner &&
(composioSortedEntries.length > 0 ? (
<div
className="grid gap-2 sm:gap-3"
style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))',
gridAutoRows: '6.5rem',
}}>
{composioSortedEntries.map(({ meta, connection }) => {
const allConns = composioConnectionsByToolkit?.get(meta.slug);
const activeCount =
allConns?.filter(c => deriveComposioState(c) === 'connected')
.length ?? 0;
<div className="grid grid-cols-2 gap-2">
{channelDefs.map(def => {
const channelId = def.id as ChannelType;
const selected =
channelConnections.defaultMessagingChannel === channelId;
return (
<div
key={meta.slug}
data-testid={`skill-row-composio-${meta.slug}`}
className="overflow-hidden">
<ComposioConnectorTile
meta={meta}
connection={connection}
activeConnectionCount={activeCount}
hasComposioError={Boolean(composioError)}
agentUnsupported={
agentReadinessKnown &&
deriveComposioState(connection) === 'connected' &&
!agentReadyComposioToolkits.has(meta.slug)
}
testId={`skill-install-composio-${meta.slug}`}
onOpen={() => setComposioModalToolkit(meta)}
onRetryGlobal={() => void refreshComposio()}
/>
</div>
<button
key={channelId}
type="button"
onClick={() => void handleSetDefaultChannel(channelId)}
disabled={defaultChannelBusy !== null}
className={`rounded-lg border px-3 py-2 text-xs font-medium transition-colors ${
selected
? 'border-primary-500/60 bg-primary-50 dark:bg-primary-500/10 text-primary-600 dark:text-primary-300'
: 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 text-stone-600 dark:text-neutral-300 hover:border-stone-300 dark:hover:border-neutral-700'
}`}>
{def.display_name}
</button>
);
})}
</div>
) : (
<p className="px-1 py-4 text-center text-xs text-stone-400 dark:text-neutral-500">
{t('skills.noResults')}
</p>
))}
</div>
)}
{activeTab === 'composio' && otherGroups.map(group => renderGroup(group))}
{activeTab === 'skills' && (
<div className="space-y-3 animate-fade-up">
<BetaBanner />
<SkillsExplorerTab onToast={addToast} />
</div>
)}
{activeTab === 'mcp' && (
<div className="space-y-3 animate-fade-up">
<BetaBanner />
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 shadow-soft">
<McpServersTab />
</div>
</div>
</div>
)}
)}
{activeTab === 'meetings' && (
<div className="space-y-3 animate-fade-up">
<BetaBanner />
<MeetingBotsCard onToast={addToast} />
</div>
)}
{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"
data-walkthrough="skills-grid"
data-testid="composio-integrations-card">
{showLocalComposioApiKeyBanner && (
<ComposioApiKeyEmptyState
onOpenSettings={() => handleTabChange('composio-key')}
/>
)}
{!showLocalComposioApiKeyBanner && (
<div className="space-y-3 px-1 pb-3">
<SkillSearchBar value={searchQuery} onChange={setSearchQuery} />
<SkillCategoryFilter
categories={availableCategories}
selected={selectedCategory}
onChange={setSelectedCategory}
/>
</div>
)}
{!showLocalComposioApiKeyBanner &&
(composioSortedEntries.length > 0 ? (
<div
className="grid gap-2 sm:gap-3"
style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(5.5rem, 1fr))',
gridAutoRows: '6.5rem',
}}>
{composioSortedEntries.map(({ meta, connection }) => {
const allConns = composioConnectionsByToolkit?.get(meta.slug);
const activeCount =
allConns?.filter(c => deriveComposioState(c) === 'connected')
.length ?? 0;
return (
<div
key={meta.slug}
data-testid={`skill-row-composio-${meta.slug}`}
className="overflow-hidden">
<ComposioConnectorTile
meta={meta}
connection={connection}
activeConnectionCount={activeCount}
hasComposioError={Boolean(composioError)}
agentUnsupported={
agentReadinessKnown &&
deriveComposioState(connection) === 'connected' &&
!agentReadyComposioToolkits.has(meta.slug)
}
testId={`skill-install-composio-${meta.slug}`}
onOpen={() => setComposioModalToolkit(meta)}
onRetryGlobal={() => void refreshComposio()}
/>
</div>
);
})}
</div>
) : (
<p className="px-1 py-4 text-center text-xs text-stone-400 dark:text-neutral-500">
{t('skills.noResults')}
</p>
))}
</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>
{activeTab === 'composio' && otherGroups.map(group => renderGroup(group))}
{activeTab === 'skills' && (
<div className="space-y-3 animate-fade-up">
<BetaBanner />
<SkillsExplorerTab onToast={addToast} />
</div>
)}
{activeTab === 'mcp' && (
<div className="space-y-3 animate-fade-up">
<BetaBanner />
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4 shadow-soft">
<McpServersTab />
</div>
</div>
)}
{activeTab === 'meetings' && (
<div className="space-y-3 animate-fade-up">
<BetaBanner />
<MeetingBotsCard onToast={addToast} />
</div>
)}
</>
}
</div>
</PanelPage>
)}
</TwoPanelLayout>
{channelModalDef && (
+63 -9
View File
@@ -31,9 +31,10 @@ vi.mock('../../hooks/useSubconscious', () => ({
}),
}));
vi.mock('../../components/intelligence/IntelligenceSubconsciousTab', () => ({
default: () => null,
}));
vi.mock('../../components/intelligence/IntelligenceSubconsciousTab', async () => {
const React = await import('react');
return { default: () => React.createElement('div', { 'data-testid': 'brain-subconscious' }) };
});
vi.mock('../../components/PillTabBar', async () => {
const React = await import('react');
return {
@@ -44,14 +45,46 @@ vi.mock('../../components/PillTabBar', async () => {
vi.mock('../../components/ui/BetaBanner', () => ({ default: () => null }));
vi.mock('../../components/intelligence/MemoryControls', () => ({ MemoryControls: () => null }));
vi.mock('../../components/intelligence/MemoryTreeStatusPanel', () => ({
MemoryTreeStatusPanel: () => null,
}));
vi.mock('../../components/intelligence/MemorySourcesRegistry', () => ({
MemorySourcesRegistry: () => null,
}));
vi.mock('../../components/intelligence/MemoryTreeStatusPanel', async () => {
const React = await import('react');
return {
MemoryTreeStatusPanel: () => React.createElement('div', { 'data-testid': 'brain-sync' }),
};
});
vi.mock('../../components/intelligence/MemorySourcesRegistry', async () => {
const React = await import('react');
return {
MemorySourcesRegistry: () => React.createElement('div', { 'data-testid': 'brain-sources' }),
};
});
vi.mock('../../components/intelligence/Toast', () => ({ ToastContainer: () => null }));
// Knowledge & Memory tabs render relocated settings panels — stub each so the
// Brain page's per-tab branches are exercised without their deep dependency trees.
vi.mock('../Intelligence', async () => {
const React = await import('react');
return { default: () => React.createElement('div', { 'data-testid': 'brain-intelligence' }) };
});
vi.mock('../../components/settings/panels/MemoryDataPanel', async () => {
const React = await import('react');
return { default: () => React.createElement('div', { 'data-testid': 'brain-memory-data' }) };
});
vi.mock('../../components/settings/panels/MemoryDebugPanel', async () => {
const React = await import('react');
return { default: () => React.createElement('div', { 'data-testid': 'brain-memory-debug' }) };
});
vi.mock('../../components/settings/panels/AnalysisViewsPanel', async () => {
const React = await import('react');
return { default: () => React.createElement('div', { 'data-testid': 'brain-analysis-views' }) };
});
vi.mock('../../components/settings/layout/SettingsLayoutContext', async () => {
const React = await import('react');
return {
SettingsLayoutProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
};
});
const makeGraph = (n: number) => ({
nodes: Array.from({ length: n }, (_, i) => ({ id: `n${i}`, kind: 'summary', label: `N${i}` })),
edges: [],
@@ -96,4 +129,25 @@ describe('Brain page', () => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
// The Knowledge & Memory tabs render relocated settings panels inside the
// two-pane shell; the bespoke tabs share the standard scaffold. Drive each via
// the `?tab=` query param so every per-tab branch is exercised.
it.each([
['intelligence', 'brain-intelligence'],
['memory-data', 'brain-memory-data'],
['memory-debug', 'brain-memory-debug'],
['analysis-views', 'brain-analysis-views'],
['sources', 'brain-sources'],
['sync', 'brain-sync'],
['subconscious', 'brain-subconscious'],
])('renders the %s tab', async (tab, testId) => {
graphExportMock.mockResolvedValue(makeGraph(0));
await act(async () => {
renderWithProviders(<Brain />, { initialEntries: [`/?tab=${tab}`] });
});
await waitFor(() => {
expect(screen.getByTestId(testId)).toBeInTheDocument();
});
});
});

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