diff --git a/app/src/components/layout/ChipTabs.test.tsx b/app/src/components/layout/ChipTabs.test.tsx new file mode 100644 index 000000000..5ea412712 --- /dev/null +++ b/app/src/components/layout/ChipTabs.test.tsx @@ -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[] = [ + { 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( {}} 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( {}} 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(); + + fireEvent.click(screen.getByTestId('t-three')); + expect(onChange).toHaveBeenCalledWith('three'); + }); + + it('uses an explicit per-item testId over the prefix', () => { + render( + {}} + 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( {}} 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'); + }); +}); diff --git a/app/src/components/layout/ChipTabs.tsx b/app/src/components/layout/ChipTabs.tsx new file mode 100644 index 000000000..02d56e9f7 --- /dev/null +++ b/app/src/components/layout/ChipTabs.tsx @@ -0,0 +1,110 @@ +import type { ReactNode } from 'react'; + +const namespace = 'chip-tabs'; + +function debug(message: string, payload?: Record) { + if (import.meta.env.DEV) { + console.debug(`[${namespace}] ${message}`, payload ?? {}); + } +} + +export interface ChipTabItem { + /** 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 { + /** Chips to render, left to right. */ + items: ChipTabItem[]; + /** 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({ + items, + value, + onChange, + as = 'tab', + ariaLabel, + testId, + testIdPrefix, + className = DEFAULT_ROW_CLASS, +}: ChipTabsProps) { + const isNav = as === 'nav'; + + return ( +
+ {items.map(item => { + const active = item.id === value; + const chipTestId = item.testId ?? (testIdPrefix ? `${testIdPrefix}-${item.id}` : undefined); + + return ( + + ); + })} +
+ ); +} diff --git a/app/src/components/layout/PanelHeader.tsx b/app/src/components/layout/PanelHeader.tsx new file mode 100644 index 000000000..aaae6ff26 --- /dev/null +++ b/app/src/components/layout/PanelHeader.tsx @@ -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 ( +
+ {hasControlRow && ( +
+
{leading}
+ {action != null &&
{action}
} +
+ )} + + {description != null && ( +

{description}

+ )} + + {children} +
+ ); +} diff --git a/app/src/components/layout/PanelPage.tsx b/app/src/components/layout/PanelPage.tsx new file mode 100644 index 000000000..b7c35d5cb --- /dev/null +++ b/app/src/components/layout/PanelPage.tsx @@ -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 { + /** 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 { + /** 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[]; + /** 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({ + description, + leading, + action, + tabs, + value, + onChange, + tabsAriaLabel, + tabsTestIdPrefix, + children, + contentClassName = DEFAULT_CONTENT_CLASS, + className = '', + testId, +}: PanelPageProps) { + const tabList = tabs ?? []; + const hasTabs = tabList.length > 0; + + // Single-body panel: the page header *is* the scaffold header. + if (!hasTabs) { + return ( + + {children} + + ); + } + + const active = tabList.find(t => t.id === value) ?? tabList[0]; + const chipItems: ChipTabItem[] = tabList.map(t => ({ + id: t.id, + label: t.label, + testId: t.chipTestId, + })); + + return ( +
+ {/* Fixed page chrome: optional description, then the chip row. */} + + onChange?.(id)} + /> + + + {/* Active tab body — its own scaffold owns the scroll. The border marks the + seam below the chips. */} +
+ + {active.content} + +
+
+ ); +} diff --git a/app/src/components/layout/PanelScaffold.tsx b/app/src/components/layout/PanelScaffold.tsx new file mode 100644 index 000000000..67385d4ea --- /dev/null +++ b/app/src/components/layout/PanelScaffold.tsx @@ -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 ( +
+ {hasHeader && ( + + {headerExtra} + + )} + +
+ {children} +
+
+ ); +} diff --git a/app/src/components/layout/TwoPanelLayout.tsx b/app/src/components/layout/TwoPanelLayout.tsx index a584fc87b..37415e9c3 100644 --- a/app/src/components/layout/TwoPanelLayout.tsx +++ b/app/src/components/layout/TwoPanelLayout.tsx @@ -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 ( -
+ // 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 && ( <>
{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. */} - + {seamless ? ( + <> + {/* Wider transparent grab strip straddling the 1px seam; z-10 + keeps it above the adjacent panes so it stays grabbable. */} + + {/* The seam line itself, brightened on hover/focus. */} + + + ) : ( + /* 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. */ + + )}
)} @@ -295,9 +323,19 @@ export default function TwoPanelLayout({ )} -
+
{children}
+ + ); + + return ( +
+ {seamless ? ( +
{panes}
+ ) : ( + panes + )}
); } diff --git a/app/src/components/settings/components/SettingsBackButton.tsx b/app/src/components/settings/components/SettingsBackButton.tsx new file mode 100644 index 000000000..c6c75fbd5 --- /dev/null +++ b/app/src/components/settings/components/SettingsBackButton.tsx @@ -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/`) 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 ? ( + + ) : ( + + ); +}; + +const RoutedSettingsBackButton = ({ onBack }: SettingsBackButtonProps) => { + const { pathname } = useLocation(); + return ; +}; + +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 ( + + ); +}; + +export default SettingsBackButton; diff --git a/app/src/components/settings/components/SettingsHeader.tsx b/app/src/components/settings/components/SettingsHeader.tsx index df1995f0a..3f181f4ba 100644 --- a/app/src/components/settings/components/SettingsHeader.tsx +++ b/app/src/components/settings/components/SettingsHeader.tsx @@ -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 `` (e.g. isolated settings-panel unit tests). Inside the - * app the header always sits within the router, so this returns the real path; - * with no router it falls back to '' which callers treat as a top-level route. - * - * Split into its own component so `useLocation` is only ever called when a - * router is actually present — keeping the rules-of-hooks contract intact. - */ -const SettingsHeader = (props: SettingsHeaderProps) => { - const inRouter = useInRouterContext(); - return inRouter ? ( - - ) : ( - - ); -}; - -const RoutedSettingsHeader = (props: SettingsHeaderProps) => { - const { pathname } = useLocation(); - return ; -}; - -const SettingsHeaderView = ({ +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/) - // hide the back button on wide viewports — the sidebar provides navigation. - // Nested pages (team/manage/:id, agents/edit/:id, …) keep it at all widths. - const isTopLevel = pathname.split('/').filter(Boolean).length <= 2; - const backButtonClass = - inTwoPaneShell && isTopLevel - ? 'md:hidden w-6 h-6 flex items-center justify-center rounded-full hover:bg-stone-100 dark:bg-neutral-800 dark:hover:bg-neutral-800 transition-colors mr-2' - : 'w-6 h-6 flex items-center justify-center rounded-full hover:bg-stone-100 dark:bg-neutral-800 dark:hover:bg-neutral-800 transition-colors mr-2'; return (
- {/* Back button */} - {showBack && onBack && ( - - )} + {/* Route-aware back button (hidden when not applicable). */} + {showBackButton && } {/* Title */}

diff --git a/app/src/components/settings/layout/SettingsLayout.tsx b/app/src/components/settings/layout/SettingsLayout.tsx index 7bae35cad..28de4452e 100644 --- a/app/src/components/settings/layout/SettingsLayout.tsx +++ b/app/src/components/settings/layout/SettingsLayout.tsx @@ -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 = () => {

}> -
-
+ {/* 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. */} +
+
- +
+ +
diff --git a/app/src/components/settings/layout/SettingsSubNav.tsx b/app/src/components/settings/layout/SettingsSubNav.tsx index e47696e40..a2e441415 100644 --- a/app/src/components/settings/layout/SettingsSubNav.tsx +++ b/app/src/components/settings/layout/SettingsSubNav.tsx @@ -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[] = 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 ( -
- {siblings.map(entry => { - const active = entry.id === currentRoute; - return ( - - ); - })} -
+ { + const entry = siblings.find(s => s.id === id); + if (entry) navigateToSettings(entryRoute(entry)); + }} + /> ); }; diff --git a/app/src/components/settings/layout/settingsNavIcons.tsx b/app/src/components/settings/layout/settingsNavIcons.tsx index 3a78c32f1..8b3fca180 100644 --- a/app/src/components/settings/layout/settingsNavIcons.tsx +++ b/app/src/components/settings/layout/settingsNavIcons.tsx @@ -43,6 +43,11 @@ export const SETTINGS_NAV_ICONS: Record = { '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') ), diff --git a/app/src/components/settings/panels/AIPanel.tsx b/app/src/components/settings/panels/AIPanel.tsx index 41abbd479..d248fbac6 100644 --- a/app/src/components/settings/panels/AIPanel.tsx +++ b/app/src/components/settings/panels/AIPanel.tsx @@ -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 ( -
- {!embedded && ( - - )} - + }>
{/* ═══════════════════════════════════════════════════════════════ @@ -3477,7 +3473,7 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => { } /> )} -
+
); }; diff --git a/app/src/components/settings/panels/AboutPanel.tsx b/app/src/components/settings/panels/AboutPanel.tsx index c77620497..ff24a00c6 100644 --- a/app/src/components/settings/panels/AboutPanel.tsx +++ b/app/src/components/settings/panels/AboutPanel.tsx @@ -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 ; // 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 ( -
- - + }>
{/* Version */} @@ -175,7 +173,7 @@ const AboutPanel = () => { relocated here from the retired Developer & Diagnostics page. */}
-
+ ); }; diff --git a/app/src/components/settings/panels/AccountPanel.tsx b/app/src/components/settings/panels/AccountPanel.tsx index e07640c8a..582545e78 100644 --- a/app/src/components/settings/panels/AccountPanel.tsx +++ b/app/src/components/settings/panels/AccountPanel.tsx @@ -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 ( -
- - -
- {(name || username) && ( -
-
- {(name ?? username ?? '?').replace('@', '').slice(0, 1).toUpperCase()} -
-
- {name && ( -
- {name} -
- )} - {username && ( -
- {username} -
- )} -
+ }> + {(name || username) && ( +
+
+ {(name ?? username ?? '?').replace('@', '').slice(0, 1).toUpperCase()} +
+
+ {name && ( +
+ {name} +
+ )} + {username && ( +
+ {username} +
+ )}
- )} - -
-
+ )} + +
+
-
+
); }; diff --git a/app/src/components/settings/panels/AgentAccessPanel.tsx b/app/src/components/settings/panels/AgentAccessPanel.tsx index 10b27afdb..982bee7f6 100644 --- a/app/src/components/settings/panels/AgentAccessPanel.tsx +++ b/app/src/components/settings/panels/AgentAccessPanel.tsx @@ -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 ( -
- - + }>
{/* Desktop-only notice */} {!isTauri() && ( @@ -439,7 +437,7 @@ const AgentAccessPanel = () => { )}
-
+ ); }; diff --git a/app/src/components/settings/panels/AgentActivityPanel.test.tsx b/app/src/components/settings/panels/AgentActivityPanel.test.tsx index 25e2bdda3..3590e9872 100644 --- a/app/src/components/settings/panels/AgentActivityPanel.test.tsx +++ b/app/src/components/settings/panels/AgentActivityPanel.test.tsx @@ -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 }) => ( -
- {title} - -
+// 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 }) => ( + ), })); @@ -73,18 +70,17 @@ beforeEach(() => { }); describe('', () => { - 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(); - // 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(); - 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('', () => { it('persists a new level selection via the update RPC', async () => { render(); - 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(); diff --git a/app/src/components/settings/panels/AgentActivityPanel.tsx b/app/src/components/settings/panels/AgentActivityPanel.tsx index 49554d684..3801b2c57 100644 --- a/app/src/components/settings/panels/AgentActivityPanel.tsx +++ b/app/src/components/settings/panels/AgentActivityPanel.tsx @@ -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(null); const [monthlyCost, setMonthlyCost] = useState(null); const [status, setStatus] = useState('loading'); @@ -108,17 +109,11 @@ export default function AgentActivityPanel() { } return ( -
- {/* 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. */} - + }>

{t('activityLevel.description')} @@ -188,6 +183,6 @@ export default function AgentActivityPanel() { savingLabel={t('autonomy.statusSaving')} />

-
+ ); } diff --git a/app/src/components/settings/panels/AgentChatPanel.tsx b/app/src/components/settings/panels/AgentChatPanel.tsx index 984a9a9fe..9a647da49 100644 --- a/app/src/components/settings/panels/AgentChatPanel.tsx +++ b/app/src/components/settings/panels/AgentChatPanel.tsx @@ -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([]); const [input, setInput] = useState(''); const [modelOverride, setModelOverride] = useState(''); @@ -82,14 +83,11 @@ const AgentChatPanel = () => { }; return ( -
- - + }>
@@ -168,7 +166,7 @@ const AgentChatPanel = () => {
-
+ ); }; diff --git a/app/src/components/settings/panels/AgentEditorPage.tsx b/app/src/components/settings/panels/AgentEditorPage.tsx index 3560ff1ab..e16bc0c38 100644 --- a/app/src/components/settings/panels/AgentEditorPage.tsx +++ b/app/src/components/settings/panels/AgentEditorPage.tsx @@ -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 ( -
- - + }>
{loading ? (
@@ -475,7 +469,7 @@ const AgentEditorPage = () => { onClose={() => setToolsOpen(false)} /> )} -
+ ); }; diff --git a/app/src/components/settings/panels/AgentsPanel.tsx b/app/src/components/settings/panels/AgentsPanel.tsx index 16dfd0bdc..ac6e6aaf4 100644 --- a/app/src/components/settings/panels/AgentsPanel.tsx +++ b/app/src/components/settings/panels/AgentsPanel.tsx @@ -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([]); const [loading, setLoading] = useState(true); @@ -92,14 +93,11 @@ const AgentsPanel = () => { ); return ( -
- - + }>

@@ -147,7 +145,7 @@ const AgentsPanel = () => { )}

-
+
); }; diff --git a/app/src/components/settings/panels/AnalysisViewsPanel.tsx b/app/src/components/settings/panels/AnalysisViewsPanel.tsx index 745ba776e..cd5f062e1 100644 --- a/app/src/components/settings/panels/AnalysisViewsPanel.tsx +++ b/app/src/components/settings/panels/AnalysisViewsPanel.tsx @@ -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('diagram'); const views: { id: AnalysisView; label: string }[] = [ @@ -48,13 +49,11 @@ const AnalysisViewsPanel = () => { ]; return ( -
- + }>
({ label: view.label, value: view.id }))} @@ -72,7 +71,7 @@ const AnalysisViewsPanel = () => { {activeView === 'paths' && } {activeView === 'namespaces' && }
-
+ ); }; diff --git a/app/src/components/settings/panels/AppearancePanel.tsx b/app/src/components/settings/panels/AppearancePanel.tsx index 93a020222..73f7ca9d9 100644 --- a/app/src/components/settings/panels/AppearancePanel.tsx +++ b/app/src/components/settings/panels/AppearancePanel.tsx @@ -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 ( -
- - + }>
{/* ── Theme picker — intentional bespoke tile UI ─────────────── */}
@@ -322,7 +320,7 @@ const AppearancePanel = () => { />
-
+
); }; diff --git a/app/src/components/settings/panels/ApprovalHistoryPanel.tsx b/app/src/components/settings/panels/ApprovalHistoryPanel.tsx index dd952073a..78082c62a 100644 --- a/app/src/components/settings/panels/ApprovalHistoryPanel.tsx +++ b/app/src/components/settings/panels/ApprovalHistoryPanel.tsx @@ -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 = { const ApprovalHistoryPanel = () => { const { t } = useT(); - const { navigateBack, breadcrumbs } = useSettingsNavigation(); + const { navigateBack } = useSettingsNavigation(); const [entries, setEntries] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -88,14 +89,10 @@ const ApprovalHistoryPanel = () => { }; return ( -
- - + }>
@@ -171,7 +168,7 @@ const ApprovalHistoryPanel = () => { )}
-
+
); }; diff --git a/app/src/components/settings/panels/AutocompleteDebugPanel.tsx b/app/src/components/settings/panels/AutocompleteDebugPanel.tsx index e6a825618..216122e6d 100644 --- a/app/src/components/settings/panels/AutocompleteDebugPanel.tsx +++ b/app/src/components/settings/panels/AutocompleteDebugPanel.tsx @@ -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(null); @@ -477,14 +478,11 @@ const AutocompleteDebugPanel = () => { // ------------------------------------------------------------------------- return ( -
- - + }>
{/* ------------------------------------------------------------------ */} {/* Runtime section */} @@ -772,7 +770,7 @@ const AutocompleteDebugPanel = () => { {/* ------------------------------------------------------------------ */}
-
+ ); }; diff --git a/app/src/components/settings/panels/AutocompletePanel.tsx b/app/src/components/settings/panels/AutocompletePanel.tsx index 59b44ac38..c4fa8aa87 100644 --- a/app/src/components/settings/panels/AutocompletePanel.tsx +++ b/app/src/components/settings/panels/AutocompletePanel.tsx @@ -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(null); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); @@ -216,14 +217,10 @@ const AutocompletePanel = () => { }; return ( -
- - + }>
{/* ── Settings ──────────────────────────────────────────────── */} @@ -394,7 +391,7 @@ const AutocompletePanel = () => {
-
+ ); }; diff --git a/app/src/components/settings/panels/BillingPanel.tsx b/app/src/components/settings/panels/BillingPanel.tsx index fbf219085..553dbab4a 100644 --- a/app/src/components/settings/panels/BillingPanel.tsx +++ b/app/src/components/settings/panels/BillingPanel.tsx @@ -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 ( -
- - + }>
@@ -48,7 +45,7 @@ const BillingPanel = () => {
-
+ ); }; diff --git a/app/src/components/settings/panels/CompanionPanel.tsx b/app/src/components/settings/panels/CompanionPanel.tsx index b841a9667..0091c58d5 100644 --- a/app/src/components/settings/panels/CompanionPanel.tsx +++ b/app/src/components/settings/panels/CompanionPanel.tsx @@ -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(null); @@ -99,14 +100,11 @@ const CompanionPanel = () => { const sessionActive = status?.active ?? false; return ( -
- - + }>
{/* Session status + controls */} @@ -222,7 +220,7 @@ const CompanionPanel = () => { savingLabel={t('settings.agentAccess.saving')} />
-
+ ); }; diff --git a/app/src/components/settings/panels/ComposioPanel.tsx b/app/src/components/settings/panels/ComposioPanel.tsx index 402118fb2..47631e372 100644 --- a/app/src/components/settings/panels/ComposioPanel.tsx +++ b/app/src/components/settings/panels/ComposioPanel.tsx @@ -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 : ; + if (loading) { return ( -
- {!embedded && ( - - )} +

{t('settings.composio.loading')}

-
+ ); } return ( -
- {!embedded && ( - - )} - +

{t('settings.composio.intro')} @@ -406,7 +399,7 @@ const ComposioPanel = ({ embedded = false, managedAuthEnabled }: ComposioPanelPr

)}
-
+ ); }; diff --git a/app/src/components/settings/panels/ComposioTriagePanel.tsx b/app/src/components/settings/panels/ComposioTriagePanel.tsx index 7ac82d525..f13dad2a7 100644 --- a/app/src/components/settings/panels/ComposioTriagePanel.tsx +++ b/app/src/components/settings/panels/ComposioTriagePanel.tsx @@ -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 ( -
- + }>

{t('settings.composio.loading')}

-
+ ); } return ( -
- - + }>

{t('composio.triageDesc')}{' '} @@ -167,7 +163,7 @@ const ComposioTriagePanel = () => { />

-
+ ); }; diff --git a/app/src/components/settings/panels/CronJobsPanel.tsx b/app/src/components/settings/panels/CronJobsPanel.tsx index 1c3807721..c7fc97270 100644 --- a/app/src/components/settings/panels/CronJobsPanel.tsx +++ b/app/src/components/settings/panels/CronJobsPanel.tsx @@ -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 ( -
- - + }>
@@ -264,7 +263,7 @@ const CronJobsPanel = () => { onUpdate={handleUpdate} /> )} -
+ ); }; diff --git a/app/src/components/settings/panels/DevWorkflowPanel.tsx b/app/src/components/settings/panels/DevWorkflowPanel.tsx index fd2a6440b..055e3574d 100644 --- a/app/src/components/settings/panels/DevWorkflowPanel.tsx +++ b/app/src/components/settings/panels/DevWorkflowPanel.tsx @@ -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([]); @@ -501,14 +502,12 @@ const DevWorkflowPanel = () => { const canSave = selectedRepo && targetBranch && schedule; return ( -
- - + }>
{/* Description */}

@@ -817,7 +816,7 @@ const DevWorkflowPanel = () => { )}

-
+ ); }; diff --git a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx index e2c927961..8ed802237 100644 --- a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx +++ b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx @@ -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 ( -
- - + }> {/* Debug-only sub-sections */}
{DEV_GROUPS.map(group => ( @@ -710,7 +708,7 @@ const DeveloperOptionsPanel = () => { {showSentryTest && }
-
+ ); }; diff --git a/app/src/components/settings/panels/DevicesPanel.tsx b/app/src/components/settings/panels/DevicesPanel.tsx index 6c5fb0e9a..80b42c4ed 100644 --- a/app/src/components/settings/panels/DevicesPanel.tsx +++ b/app/src/components/settings/panels/DevicesPanel.tsx @@ -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([]); const [loading, setLoading] = useState(true); @@ -281,19 +282,16 @@ const DevicesPanel = () => { }; return ( -
- - {t('devices.pairIphone')} - - } - /> - + } + action={ + + }>
{/* Bespoke beta badge — intentional marketing chip */} {t('devices.betaBadge')} @@ -384,7 +382,7 @@ const DevicesPanel = () => { {showPairModal && } -
+
); }; diff --git a/app/src/components/settings/panels/EmbeddingsPanel.tsx b/app/src/components/settings/panels/EmbeddingsPanel.tsx index 878690a94..27dabc1de 100644 --- a/app/src/components/settings/panels/EmbeddingsPanel.tsx +++ b/app/src/components/settings/panels/EmbeddingsPanel.tsx @@ -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(null); const [status, setStatus] = useState({ kind: 'loading' }); @@ -86,15 +87,11 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => { if (!settings) { return ( -
- {!embedded && ( - - )} + }>
{status.kind === 'loading' @@ -104,7 +101,7 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => { : ''}
-
+ ); } @@ -332,16 +329,11 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => { } return ( -
- {!embedded && ( - - )} - + }>

{t('settings.embeddings.description')} @@ -708,7 +700,7 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {

)} -
+ ); }; diff --git a/app/src/components/settings/panels/EventLogPanel.tsx b/app/src/components/settings/panels/EventLogPanel.tsx index 03d359ef9..3d7b00506 100644 --- a/app/src/components/settings/panels/EventLogPanel.tsx +++ b/app/src/components/settings/panels/EventLogPanel.tsx @@ -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([]); const [isLive, setIsLive] = useState(false); const [filterType, setFilterType] = useState(''); @@ -211,14 +212,12 @@ const EventLogPanel = () => { const domains = [...new Set(entries.map(e => e.domain))].sort(); return ( -
- - + }>
{/* Status bar */}
@@ -327,7 +326,7 @@ const EventLogPanel = () => {
-
+ ); }; diff --git a/app/src/components/settings/panels/IntegrationsPanel.tsx b/app/src/components/settings/panels/IntegrationsPanel.tsx index 034609f3d..38c19c4f7 100644 --- a/app/src/components/settings/panels/IntegrationsPanel.tsx +++ b/app/src/components/settings/panels/IntegrationsPanel.tsx @@ -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 = { - 'task-sources': '', - composio: '#composio', - webhooks: '#webhooks', -}; +const TAB_HASH: Record = { '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 ; + } + // 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 ( -
- - -
- {tabs.map(({ id, label }) => { - const selected = tab === id; - return ( - - ); - })} -
- - {tab === 'task-sources' && } - {tab === 'composio' && ( -
- -
- )} - {tab === 'webhooks' && } -
+ + className="z-10" + description={t('settings.integrations.menuDesc')} + leading={} + tabsAriaLabel={t('settings.integrations.title')} + tabsTestIdPrefix="integrations-tab" + value={tab} + onChange={selectTab} + tabs={[ + { + id: 'task-sources', + label: t('settings.taskSources.title'), + content: , + }, + { + id: 'webhooks', + label: t('settings.developerMenu.composeioTriggers.title'), + content: , + }, + ]} + /> ); }; diff --git a/app/src/components/settings/panels/LocalModelDebugPanel.tsx b/app/src/components/settings/panels/LocalModelDebugPanel.tsx index c31038282..e2b5f5d16 100644 --- a/app/src/components/settings/panels/LocalModelDebugPanel.tsx +++ b/app/src/components/settings/panels/LocalModelDebugPanel.tsx @@ -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(null); const [assets, setAssets] = useState(null); @@ -378,14 +379,11 @@ const LocalModelDebugPanel = () => { }; return ( -
- - + }>
{ onRunTtsTest={() => void runTtsTest()} />
-
+ ); }; diff --git a/app/src/components/settings/panels/McpServerPanel.tsx b/app/src/components/settings/panels/McpServerPanel.tsx index ee0367abd..b86829db4 100644 --- a/app/src/components/settings/panels/McpServerPanel.tsx +++ b/app/src/components/settings/panels/McpServerPanel.tsx @@ -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(null); const [binaryError, setBinaryError] = useState(null); @@ -168,16 +170,11 @@ const McpServerPanel = ({ embedded = false }: McpServerPanelProps = {}) => { ]; return ( -
- {!embedded && ( - - )} - + }> {/* ----------------------------------------------------------------- */} {/* 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 */} -
-
- {clients.map(client => ( - - ))} -
-
+ { + setActiveClient(id); + setOpenConfigError(null); + }} + /> {/* Binary path error banner */} {binaryError && ( @@ -287,7 +267,7 @@ const McpServerPanel = ({ embedded = false }: McpServerPanelProps = {}) => { )}
-
+
); }; diff --git a/app/src/components/settings/panels/MemoryDataPanel.tsx b/app/src/components/settings/panels/MemoryDataPanel.tsx index ae9cc61db..a3281efd3 100644 --- a/app/src/components/settings/panels/MemoryDataPanel.tsx +++ b/app/src/components/settings/panels/MemoryDataPanel.tsx @@ -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([]); const addToast = useCallback((toast: Omit) => { @@ -48,15 +49,11 @@ const MemoryDataPanel = ({ embedded = false }: MemoryDataPanelProps = {}) => { ); return ( -
- {!embedded && ( - - )} + }>

@@ -94,7 +91,7 @@ const MemoryDataPanel = ({ embedded = false }: MemoryDataPanelProps = {}) => {

-
+ ); }; diff --git a/app/src/components/settings/panels/MemoryDebugPanel.tsx b/app/src/components/settings/panels/MemoryDebugPanel.tsx index a0078f4c5..d3a87cdd3 100644 --- a/app/src/components/settings/panels/MemoryDebugPanel.tsx +++ b/app/src/components/settings/panels/MemoryDebugPanel.tsx @@ -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([]); const [documentsRaw, setDocumentsRaw] = useState(null); const [documentsNamespaceFilter, setDocumentsNamespaceFilter] = useState(''); @@ -196,14 +197,12 @@ const MemoryDebugPanel = () => { }, [clearNamespaceInput, refreshAll, t]); return ( -
- - + }>
{/* Documents */} @@ -438,7 +437,7 @@ const MemoryDebugPanel = () => {
-
+ ); }; diff --git a/app/src/components/settings/panels/MemorySyncPanel.tsx b/app/src/components/settings/panels/MemorySyncPanel.tsx index d73bb99a9..0546ce8f4 100644 --- a/app/src/components/settings/panels/MemorySyncPanel.tsx +++ b/app/src/components/settings/panels/MemorySyncPanel.tsx @@ -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([]); const addToast = useCallback((toast: Omit) => { @@ -35,13 +36,11 @@ const MemorySyncPanel = () => { }; return ( -
- + }>

{t('settings.dataSync.description')} @@ -55,7 +54,7 @@ const MemorySyncPanel = () => {

-
+ ); }; diff --git a/app/src/components/settings/panels/MigrationPanel.tsx b/app/src/components/settings/panels/MigrationPanel.tsx index 96275a2a3..6bdb8e175 100644 --- a/app/src/components/settings/panels/MigrationPanel.tsx +++ b/app/src/components/settings/panels/MigrationPanel.tsx @@ -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('openclaw'); const [sourcePath, setSourcePath] = useState(''); @@ -134,16 +135,11 @@ const MigrationPanel = ({ embedded = false }: MigrationPanelProps = {}) => { const reportToRender = appliedReport ?? previewReport; return ( -
- {!embedded && ( - - )} - + }>

{t('migration.description')} @@ -310,7 +306,7 @@ const MigrationPanel = ({ embedded = false }: MigrationPanelProps = {}) => { )}

-
+ ); }; diff --git a/app/src/components/settings/panels/ModelHealthPanel.tsx b/app/src/components/settings/panels/ModelHealthPanel.tsx index 0161ac283..f7a042aca 100644 --- a/app/src/components/settings/panels/ModelHealthPanel.tsx +++ b/app/src/components/settings/panels/ModelHealthPanel.tsx @@ -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 { const { t } = useT(); - const { navigateBack, breadcrumbs } = useSettingsNavigation(); + const { navigateBack } = useSettingsNavigation(); const [models, setModels] = useState([]); const [config, setConfig] = useState({ hallucination_threshold: 0.1, @@ -167,13 +168,12 @@ const ModelHealthPanel = () => { const sortIcon = (col: SortCol) => (sortCol === col ? (sortAsc ? ' ↑' : ' ↓') : ''); return ( -
- + }>
{
)} -
+ ); }; diff --git a/app/src/components/settings/panels/NotificationsTabbedPanel.tsx b/app/src/components/settings/panels/NotificationsTabbedPanel.tsx index 2a5053bd8..f4e9a11f1 100644 --- a/app/src/components/settings/panels/NotificationsTabbedPanel.tsx +++ b/app/src/components/settings/panels/NotificationsTabbedPanel.tsx @@ -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 ( -
- - -
- {tabs.map(({ id, label }) => { - const selected = tab === id; - return ( - - ); - })} -
- - {tab === 'preferences' ? ( - - ) : ( - - )} -
+ + className="z-10" + description={t('settings.notifications.menuDesc')} + leading={} + tabsAriaLabel={t('settings.notifications')} + value={tab} + onChange={selectTab} + tabs={[ + { + id: 'preferences', + label: t('settings.notifications.tabs.preferences'), + content: , + }, + { + id: 'routing', + label: t('settings.notifications.tabs.routing'), + content: , + }, + ]} + /> ); }; diff --git a/app/src/components/settings/panels/PermissionsPanel.tsx b/app/src/components/settings/panels/PermissionsPanel.tsx index da3670b8b..357eab159 100644 --- a/app/src/components/settings/panels/PermissionsPanel.tsx +++ b/app/src/components/settings/panels/PermissionsPanel.tsx @@ -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 ( -
- - + }>
{!isTauri() && (

@@ -362,7 +359,7 @@ const PermissionsPanel = () => { )}

-
+ ); }; diff --git a/app/src/components/settings/panels/PersonalityPanel.tsx b/app/src/components/settings/panels/PersonalityPanel.tsx index 823b17c7d..cee6427ff 100644 --- a/app/src/components/settings/panels/PersonalityPanel.tsx +++ b/app/src/components/settings/panels/PersonalityPanel.tsx @@ -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 ( -
- - -
- {tabs.map(({ id, label }) => { - const selected = tab === id; - return ( - - ); - })} -
- - {tab === 'personality' ? : } -
+ + className="z-10" + description={t('settings.personalityFace.menuDesc')} + leading={} + tabsAriaLabel={t('settings.personalityFace.title')} + tabsTestIdPrefix="personality-tab" + value={tab} + onChange={selectTab} + tabs={[ + { + id: 'personality', + label: t('settings.assistant.personality'), + content: , + }, + { + id: 'face', + label: t('settings.assistant.faceMascot'), + content: , + }, + ]} + /> ); }; diff --git a/app/src/components/settings/panels/PrivacyPanel.tsx b/app/src/components/settings/panels/PrivacyPanel.tsx index 849125882..2bc8ade0a 100644 --- a/app/src/components/settings/panels/PrivacyPanel.tsx +++ b/app/src/components/settings/panels/PrivacyPanel.tsx @@ -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 ( -
- - + }>
{/* What leaves my computer */} @@ -223,7 +222,7 @@ const PrivacyPanel = () => {
-
+ ); }; diff --git a/app/src/components/settings/panels/ProfileEditorPage.test.tsx b/app/src/components/settings/panels/ProfileEditorPage.test.tsx index bb81766e1..b23605c75 100644 --- a/app/src/components/settings/panels/ProfileEditorPage.test.tsx +++ b/app/src/components/settings/panels/ProfileEditorPage.test.tsx @@ -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 diff --git a/app/src/components/settings/panels/ProfileEditorPage.tsx b/app/src/components/settings/panels/ProfileEditorPage.tsx index 5e4c98489..761e7b239 100644 --- a/app/src/components/settings/panels/ProfileEditorPage.tsx +++ b/app/src/components/settings/panels/ProfileEditorPage.tsx @@ -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 ( -
- - + }>
{notFound ? (
@@ -404,7 +398,7 @@ const ProfileEditorPage = () => {
)}
-
+ ); }; diff --git a/app/src/components/settings/panels/ProfilesPanel.tsx b/app/src/components/settings/panels/ProfilesPanel.tsx index 222baed6e..7e469acee 100644 --- a/app/src/components/settings/panels/ProfilesPanel.tsx +++ b/app/src/components/settings/panels/ProfilesPanel.tsx @@ -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 ( -
- navigate('/settings')} - breadcrumbs={breadcrumbs} - action={ - - } - /> - + navigate('/settings')} />} + action={ + + }>

{t('settings.profiles.subtitle')} @@ -169,7 +164,7 @@ const ProfilesPanel = () => { )}

-
+ ); }; diff --git a/app/src/components/settings/panels/RecoveryPhrasePanel.tsx b/app/src/components/settings/panels/RecoveryPhrasePanel.tsx index c973c2a5c..2a9883da7 100644 --- a/app/src/components/settings/panels/RecoveryPhrasePanel.tsx +++ b/app/src/components/settings/panels/RecoveryPhrasePanel.tsx @@ -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 ( -
- - + }>
{success ? ( @@ -493,7 +491,7 @@ const RecoveryPhrasePanel = () => { )}
-
+ ); }; diff --git a/app/src/components/settings/panels/SandboxSettingsPanel.tsx b/app/src/components/settings/panels/SandboxSettingsPanel.tsx index d7f4fb938..8403926ae 100644 --- a/app/src/components/settings/panels/SandboxSettingsPanel.tsx +++ b/app/src/components/settings/panels/SandboxSettingsPanel.tsx @@ -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 ( -
- + }>

{t('settings.sandbox.desktopOnly')}

-
+ ); } if (isLoading) { return ( -
- + }>

{t('settings.sandbox.loading')}

-
+ ); } return ( -
- - + }>
{/* Status section */} @@ -340,7 +336,7 @@ const SandboxSettingsPanel = () => { savingLabel={t('settings.sandbox.saving')} />
-
+ ); }; diff --git a/app/src/components/settings/panels/ScreenAwarenessDebugPanel.tsx b/app/src/components/settings/panels/ScreenAwarenessDebugPanel.tsx index 5b28eeb8f..b74d6d8e0 100644 --- a/app/src/components/settings/panels/ScreenAwarenessDebugPanel.tsx +++ b/app/src/components/settings/panels/ScreenAwarenessDebugPanel.tsx @@ -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 ( -
- - + }>
{/* Advanced policy settings */} @@ -296,7 +294,7 @@ const ScreenAwarenessDebugPanel = () => { {/* Error notice */} {lastError && }
-
+ ); }; diff --git a/app/src/components/settings/panels/ScreenIntelligencePanel.tsx b/app/src/components/settings/panels/ScreenIntelligencePanel.tsx index 3eecf12d1..955b50a16 100644 --- a/app/src/components/settings/panels/ScreenIntelligencePanel.tsx +++ b/app/src/components/settings/panels/ScreenIntelligencePanel.tsx @@ -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 ( -
- - + }>
{(status?.platform_supported ?? true) && ( {
)}
-
+ ); }; diff --git a/app/src/components/settings/panels/SearchPanel.tsx b/app/src/components/settings/panels/SearchPanel.tsx index de38e6bb7..41dce4706 100644 --- a/app/src/components/settings/panels/SearchPanel.tsx +++ b/app/src/components/settings/panels/SearchPanel.tsx @@ -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 ( -
- {!embedded && ( - - )} - + }>

{t('settings.search.description')} @@ -462,7 +459,7 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => { )}

-
+ ); }; diff --git a/app/src/components/settings/panels/SecurityPanel.tsx b/app/src/components/settings/panels/SecurityPanel.tsx index 2de9c2f62..929959dd1 100644 --- a/app/src/components/settings/panels/SecurityPanel.tsx +++ b/app/src/components/settings/panels/SecurityPanel.tsx @@ -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 { - 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 ( -
- - + }>
{/* Storage mode */} @@ -146,7 +145,7 @@ const SecurityPanel = () => { savingLabel={t('keyring.consent.retrying')} />
-
+ ); }; diff --git a/app/src/components/settings/panels/TasksPanel.tsx b/app/src/components/settings/panels/TasksPanel.tsx index bd36173ac..80cab0c6d 100644 --- a/app/src/components/settings/panels/TasksPanel.tsx +++ b/app/src/components/settings/panels/TasksPanel.tsx @@ -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 ( -
- - + }>

{t('memory.tab.tasksDescription')}

-
+ ); }; diff --git a/app/src/components/settings/panels/TeamInvitesPanel.tsx b/app/src/components/settings/panels/TeamInvitesPanel.tsx index 5f488555c..f3cfd1c68 100644 --- a/app/src/components/settings/panels/TeamInvitesPanel.tsx +++ b/app/src/components/settings/panels/TeamInvitesPanel.tsx @@ -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 ( -
- - + }>
{error && } @@ -344,7 +342,7 @@ const TeamInvitesPanel = () => {
)}
-
+ ); }; diff --git a/app/src/components/settings/panels/TeamManagementPanel.tsx b/app/src/components/settings/panels/TeamManagementPanel.tsx index e25237c1d..4df2131f1 100644 --- a/app/src/components/settings/panels/TeamManagementPanel.tsx +++ b/app/src/components/settings/panels/TeamManagementPanel.tsx @@ -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 ( -
- + }>

{t('team.notFound')}

-
+ ); } if (!isAdmin) { return ( -
- + }>

{t('team.accessDenied')}

-
+ ); } const { team } = teamEntry; return ( -
- - + }>
{/* Team Info */} @@ -346,7 +340,7 @@ const TeamManagementPanel = () => {
)}
-
+ ); }; diff --git a/app/src/components/settings/panels/TeamMembersPanel.tsx b/app/src/components/settings/panels/TeamMembersPanel.tsx index 2a76ad1a7..eb903c9c5 100644 --- a/app/src/components/settings/panels/TeamMembersPanel.tsx +++ b/app/src/components/settings/panels/TeamMembersPanel.tsx @@ -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 ( -
- - + }>
{error && } @@ -358,7 +356,7 @@ const TeamMembersPanel = () => {
)}
-
+ ); }; diff --git a/app/src/components/settings/panels/TeamPanel.tsx b/app/src/components/settings/panels/TeamPanel.tsx index f4f11eb89..bed988c9d 100644 --- a/app/src/components/settings/panels/TeamPanel.tsx +++ b/app/src/components/settings/panels/TeamPanel.tsx @@ -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 ( -
- - + }>
{error && } @@ -367,7 +365,7 @@ const TeamPanel = () => {
)}
-
+ ); }; diff --git a/app/src/components/settings/panels/ToolPolicyDiagnosticsPanel.tsx b/app/src/components/settings/panels/ToolPolicyDiagnosticsPanel.tsx index d922203c4..635cd7b50 100644 --- a/app/src/components/settings/panels/ToolPolicyDiagnosticsPanel.tsx +++ b/app/src/components/settings/panels/ToolPolicyDiagnosticsPanel.tsx @@ -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 ( -
- + }> {body} -
+ ); }; diff --git a/app/src/components/settings/panels/ToolsPanel.test.tsx b/app/src/components/settings/panels/ToolsPanel.test.tsx index 53af82248..593ab3a09 100644 --- a/app/src/components/settings/panels/ToolsPanel.test.tsx +++ b/app/src/components/settings/panels/ToolsPanel.test.tsx @@ -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('', () => { ); }); - 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(); - 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(); - // The header mock outputs a

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 () => { diff --git a/app/src/components/settings/panels/ToolsPanel.tsx b/app/src/components/settings/panels/ToolsPanel.tsx index cf4628bd5..4931ecfeb 100644 --- a/app/src/components/settings/panels/ToolsPanel.tsx +++ b/app/src/components/settings/panels/ToolsPanel.tsx @@ -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 ( -
- {!embedded && ( - - )} - + }>

{t('settings.tools.chooseCapabilities')} @@ -161,7 +157,7 @@ const ToolsPanel = ({ embedded = false }: ToolsPanelProps = {}) => { savingLabel="" />

-
+ ); }; diff --git a/app/src/components/settings/panels/UsagePanel.tsx b/app/src/components/settings/panels/UsagePanel.tsx index fee70f076..f69171f05 100644 --- a/app/src/components/settings/panels/UsagePanel.tsx +++ b/app/src/components/settings/panels/UsagePanel.tsx @@ -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 ( -
- - -
- {tabs.map(({ id, label }) => { - const selected = tab === id; - return ( - - ); - })} -
- - {tab === 'costs' ? : } -
+ + className="z-10" + description={t('settings.usage.menuDesc')} + leading={} + tabsAriaLabel={t('settings.usage.title')} + tabsTestIdPrefix="usage-tab" + value={tab} + onChange={selectTab} + tabs={[ + { + id: 'costs', + label: t('settings.costDashboard.title'), + content: , + }, + { + id: 'background', + label: t('settings.heartbeat.title'), + content: , + }, + ]} + /> ); }; diff --git a/app/src/components/settings/panels/VoiceDebugPanel.tsx b/app/src/components/settings/panels/VoiceDebugPanel.tsx index 846c3f104..1aa28d3ef 100644 --- a/app/src/components/settings/panels/VoiceDebugPanel.tsx +++ b/app/src/components/settings/panels/VoiceDebugPanel.tsx @@ -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(null); const [savedSettings, setSavedSettings] = useState(null); const [serverStatus, setServerStatus] = useState(null); @@ -123,14 +124,11 @@ const VoiceDebugPanel = () => { }; return ( -
- - + }>
{/* Runtime status section */} {
-

+ ); }; diff --git a/app/src/components/settings/panels/VoicePanel.tsx b/app/src/components/settings/panels/VoicePanel.tsx index 89683ee9f..8e6fc11b3 100644 --- a/app/src/components/settings/panels/VoicePanel.tsx +++ b/app/src/components/settings/panels/VoicePanel.tsx @@ -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(null); const [savedSettings, setSavedSettings] = useState(null); const [voiceStatus, setVoiceStatus] = useState(null); @@ -486,16 +487,11 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => { const piperReady = piperInstall?.state === 'installed'; return ( -
- {!embedded && ( - - )} - + }>
{/* ─── Always-on listening (Phase 2) ──────────────────────────── */} {settings && ( @@ -1303,7 +1299,7 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => { savingLabel={t('common.loading')} />
-
+ ); }; diff --git a/app/src/components/settings/panels/WalletBalancesPanel.tsx b/app/src/components/settings/panels/WalletBalancesPanel.tsx index 20fede0a2..40f1e2f59 100644 --- a/app/src/components/settings/panels/WalletBalancesPanel.tsx +++ b/app/src/components/settings/panels/WalletBalancesPanel.tsx @@ -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(null); const [loading, setLoading] = useState(false); @@ -452,38 +453,35 @@ const WalletBalancesPanel = () => { }; return ( -
- 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"> - - - - {t('walletBalances.refresh')} - - } - /> - + } + action={ + + }>
{renderContent()}
@@ -498,7 +496,7 @@ const WalletBalancesPanel = () => { {receiveTarget && ( setReceiveTarget(null)} /> )} -
+ ); }; diff --git a/app/src/components/settings/panels/WebhooksDebugPanel.tsx b/app/src/components/settings/panels/WebhooksDebugPanel.tsx index feeb2eab8..836bebc65 100644 --- a/app/src/components/settings/panels/WebhooksDebugPanel.tsx +++ b/app/src/components/settings/panels/WebhooksDebugPanel.tsx @@ -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([]); const [logs, setLogs] = useState([]); @@ -162,14 +163,12 @@ const WebhooksDebugPanel = () => { }, [loadData, t]); return ( -
- - + }>
{/* Status bar */}
@@ -342,7 +341,7 @@ const WebhooksDebugPanel = () => {
-
+ ); }; diff --git a/app/src/components/settings/panels/WorkflowRunnerPanel.test.tsx b/app/src/components/settings/panels/WorkflowRunnerPanel.test.tsx index 8d40f1400..fba34fa73 100644 --- a/app/src/components/settings/panels/WorkflowRunnerPanel.test.tsx +++ b/app/src/components/settings/panels/WorkflowRunnerPanel.test.tsx @@ -8,21 +8,17 @@ vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) vi.mock('../../skills/WorkflowRunnerBody', () => ({ default: () =>
, })); -vi.mock('../components/SettingsHeader', () => ({ - default: ({ title }: { title: string }) =>
{title}
, -})); 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( ); - expect(screen.getByTestId('settings-header')).toBeInTheDocument(); expect(screen.getByTestId('skills-runner-body')).toBeInTheDocument(); }); }); diff --git a/app/src/components/settings/panels/WorkflowRunnerPanel.tsx b/app/src/components/settings/panels/WorkflowRunnerPanel.tsx index 7f4fe5480..d07d81d4c 100644 --- a/app/src/components/settings/panels/WorkflowRunnerPanel.tsx +++ b/app/src/components/settings/panels/WorkflowRunnerPanel.tsx @@ -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 ( -
- + }>
-
+ ); }; diff --git a/app/src/components/settings/panels/__tests__/AgentChatPanel.test.tsx b/app/src/components/settings/panels/__tests__/AgentChatPanel.test.tsx index aa1c6625f..69a2c422c 100644 --- a/app/src/components/settings/panels/__tests__/AgentChatPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/AgentChatPanel.test.tsx @@ -40,9 +40,8 @@ describe('AgentChatPanel', () => { it('renders the panel header and empty conversation area', async () => { renderWithProviders(); - 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 () => { diff --git a/app/src/components/settings/panels/__tests__/AutocompletePanel.test.tsx b/app/src/components/settings/panels/__tests__/AutocompletePanel.test.tsx index 5d2507755..9ff823e87 100644 --- a/app/src/components/settings/panels/__tests__/AutocompletePanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/AutocompletePanel.test.tsx @@ -120,7 +120,7 @@ describe('AutocompletePanel (simplified)', () => { it('shows user-facing settings and can save style preset changes', async () => { renderWithProviders(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { initialEntries: ['/settings/autocomplete'] }); - await screen.findByText('Autocomplete'); + await screen.findByText('Style Preset'); await waitFor(() => expect(screen.getByText('Running: No')).toBeInTheDocument()); diff --git a/app/src/components/settings/panels/__tests__/ComposioPanel.test.tsx b/app/src/components/settings/panels/__tests__/ComposioPanel.test.tsx index 89acbfa4a..e3644dd0b 100644 --- a/app/src/components/settings/panels/__tests__/ComposioPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/ComposioPanel.test.tsx @@ -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 () => { diff --git a/app/src/components/settings/panels/__tests__/ComposioTriagePanel.test.tsx b/app/src/components/settings/panels/__tests__/ComposioTriagePanel.test.tsx index 01fba128a..957bc5470 100644 --- a/app/src/components/settings/panels/__tests__/ComposioTriagePanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/ComposioTriagePanel.test.tsx @@ -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(); diff --git a/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx b/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx index bffac7699..5188f3683 100644 --- a/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx @@ -45,10 +45,6 @@ vi.mock('../../hooks/useSettingsNavigation', () => ({ }), })); -vi.mock('../../components/SettingsHeader', () => ({ - default: ({ title }: { title: string }) =>
{title}
, -})); - // 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(); // 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(); // 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 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(() => { diff --git a/app/src/components/settings/panels/__tests__/IntegrationsPanel.test.tsx b/app/src/components/settings/panels/__tests__/IntegrationsPanel.test.tsx index bce050f84..b8f73b5ac 100644 --- a/app/src/components/settings/panels/__tests__/IntegrationsPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/IntegrationsPanel.test.tsx @@ -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
{`${location.pathname}${location.search}`}
; +}; + // 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 }) => ( -
- ), -})); - vi.mock('../../../../pages/Webhooks', () => ({ default: ({ embedded }: { embedded?: boolean }) => (
@@ -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(, { - initialEntries: ['/settings/integrations#composio'], - }); - - expect(screen.getByTestId('integrations-tab-composio')).toHaveAttribute( - 'aria-selected', - 'true' - ); - expect(screen.getByTestId('stub-composio')).toHaveAttribute('data-embedded', 'true'); - expect(screen.queryByTestId('stub-task-sources')).not.toBeInTheDocument(); - }); - test('#webhooks hash selects the Webhooks tab embedded', () => { renderWithProviders(, { initialEntries: ['/settings/integrations#webhooks'], @@ -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( + <> + + + , + { initialEntries: ['/settings/integrations#composio'] } + ); + + expect(screen.getByTestId('location-probe')).toHaveTextContent('/connections?tab=composio-key'); + }); + test('clicking tabs switches the view in place', async () => { renderWithProviders(, { initialEntries: ['/settings/integrations'] }); - fireEvent.click(screen.getByTestId('integrations-tab-composio')); - await screen.findByTestId('stub-composio'); - fireEvent.click(screen.getByTestId('integrations-tab-webhooks')); await screen.findByTestId('stub-webhooks'); diff --git a/app/src/components/settings/panels/__tests__/MemoryDebugPanel.test.tsx b/app/src/components/settings/panels/__tests__/MemoryDebugPanel.test.tsx index b6a13b00f..9d08e5044 100644 --- a/app/src/components/settings/panels/__tests__/MemoryDebugPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/MemoryDebugPanel.test.tsx @@ -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 }) => ( diff --git a/app/src/components/settings/panels/__tests__/TasksPanel.test.tsx b/app/src/components/settings/panels/__tests__/TasksPanel.test.tsx index 401420650..d7728d7c8 100644 --- a/app/src/components/settings/panels/__tests__/TasksPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/TasksPanel.test.tsx @@ -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(); diff --git a/app/src/components/settings/panels/__tests__/TeamInvitesPanel.test.tsx b/app/src/components/settings/panels/__tests__/TeamInvitesPanel.test.tsx index e316c3e43..a9c03f0c9 100644 --- a/app/src/components/settings/panels/__tests__/TeamInvitesPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/TeamInvitesPanel.test.tsx @@ -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' }), diff --git a/app/src/components/settings/panels/__tests__/TeamManagementPanel.test.tsx b/app/src/components/settings/panels/__tests__/TeamManagementPanel.test.tsx index 10fde7a27..a9f44aaaf 100644 --- a/app/src/components/settings/panels/__tests__/TeamManagementPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/TeamManagementPanel.test.tsx @@ -65,6 +65,7 @@ vi.mock('../../hooks/useSettingsNavigation', () => ({ vi.mock('../../components/SettingsHeader', () => ({ default: ({ title }: { title: string }) =>

{title}

, })); +vi.mock('../../components/SettingsBackButton', () => ({ default: () => null })); const mockUpdateTeam = vi.fn(); const mockDeleteTeam = vi.fn(); diff --git a/app/src/components/settings/panels/__tests__/TeamMembersPanel.test.tsx b/app/src/components/settings/panels/__tests__/TeamMembersPanel.test.tsx index d8d4c60bd..6578ca9ef 100644 --- a/app/src/components/settings/panels/__tests__/TeamMembersPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/TeamMembersPanel.test.tsx @@ -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' }), diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 38cc6bfef..3dc2c1305 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': 'البحث في الذكريات...', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 89eec79b0..6c4c2cd9c 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': 'মেমোরি খুঁজুন...', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 4c4e250af..80ae3772a 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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...', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 05019d68c..48c9383be 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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...', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 5608f739a..1bb7dd68d 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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...', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index f16ac64cf..8028bfb6d 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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…', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 088e977a3..c093bbfe3 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': 'मेमोरी सर्च करें...', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index cfb468b10..ea0cb52d7 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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...', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 224b6f2ae..8e3693b00 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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...', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index b70fb21b8..27060c460 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': '메모리 검색...', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 0a7d05252..72fb41d4a 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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...', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index fbac0a4ce..fb9e062c9 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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...', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index b617e3e25..df2f3768e 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': 'Поиск воспоминаний...', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 0173bf0bc..21c0b1322 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': '搜索记忆...', diff --git a/app/src/pages/Brain.tsx b/app/src/pages/Brain.tsx index b084a3808..78b16c0bd 100644 --- a/app/src/pages/Brain.tsx +++ b/app/src/pages/Brain.tsx @@ -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={ }> -
-
- {activeTab === 'graph' && ( -
- - - {graph ? ( - + {/* Distinct tab query key so the embedded Intelligence panel's + internal tab switches don't overwrite Brain's own + `?tab=intelligence` and unmount it. */} + {activeTab === 'intelligence' && } + {activeTab === 'memory-data' && } + {activeTab === 'memory-debug' && } + {activeTab === 'analysis-views' && } + + ) : ( + // Bespoke tabs share the standard scaffold: a single scrolling body, + // all custom controls live inside it. + +
+ {activeTab === 'graph' && ( +
+ - ) : error ? ( -
- {t('brain.error')} + + {graph ? ( + + ) : error ? ( +
+ {t('brain.error')} +
+ ) : null} +
+ )} + + {activeTab === 'sources' && ( +
+ +
+ )} + + {activeTab === 'sync' && ( +
+
+
- ) : null} -
- )} - - {activeTab === 'sources' && ( -
- -
- )} - - {activeTab === 'sync' && ( -
-
-
-
- )} + )} - {/* Knowledge & Memory panels relocated from Settings. Wrapped in the - settings two-pane context so their headers hide the back button - (the Brain sidebar provides navigation here). */} - {KNOWLEDGE_TABS.has(activeTab) && ( -
- - {/* Use a distinct tab query key so the embedded Intelligence - panel's internal tab switches don't overwrite Brain's own - `?tab=intelligence` and unmount it. */} - {activeTab === 'intelligence' && } - {activeTab === 'memory-data' && } - {activeTab === 'memory-debug' && } - {activeTab === 'analysis-views' && } - -
- )} - - {activeTab === 'subconscious' && ( -
- -
- + {activeTab === 'subconscious' && ( +
+ +
+ +
-
- )} -
-
+ )} +
+
+ )} diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 17c055fb3..9e5099613 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -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
{children}
; + // The surrounding two-pane card (bg / border / rounding) is provided by + // SettingsLayout's content pane, so panels sit directly on it. This wrapper + // fills the bounded Outlet area and is the page's single vertical scroll + // region: PanelScaffold-based panels are `h-full` and own their own internal + // scroll (so this never scrolls for them), while legacy panels that overflow + // scroll here. Either way there's exactly one scrollbar. + return
{children}
; }; /** @@ -193,7 +195,11 @@ const Settings = () => { path="notifications-hub" element={} /> - } /> + {/* Composio (API key + routing) moved to Connections → API keys. */} + } + /> {/* Merged Usage & Limits page */} } /> { } /> } + element={} /> = new Set([ '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={ }> -
-
- {/*
+ {/* 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) ? ( + + {activeTab === 'llm' && } + {activeTab === 'voice' && } + {activeTab === 'embeddings' && } + {activeTab === 'search' && } + {activeTab === 'composio-key' && } + + ) : ( + +
+ {/*

Skills @@ -929,202 +956,183 @@ export default function Skills() {

*/} - {composioError && ( -
-
-
-

- {t('skills.composio.staleStatusTitle')} -

-

{composioError}

+ {composioError && ( +
+
+
+

+ {t('skills.composio.staleStatusTitle')} +

+

{composioError}

+
+
-
-
- )} + )} - { - <> - {activeTab === 'channels' && channelsGroup && ( -
-
-

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

-

- {t('channels.defaultMessaging')} -

-
-
- {channelsGroup.items.map(item => ( -
- setChannelModalDef(item.channelDef!)} - /> + { + <> + {activeTab === 'channels' && channelsGroup && ( +
+
+

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

+

+ {t('channels.defaultMessaging')} +

+
+
+ {channelsGroup.items.map(item => ( +
+ setChannelModalDef(item.channelDef!)} + /> +
+ ))} +
+ +
+
+ {t('channels.defaultMessaging')}
- ))} -
- -
-
- {t('channels.defaultMessaging')} -
-
- {channelDefs.map(def => { - const channelId = def.id as ChannelType; - const selected = channelConnections.defaultMessagingChannel === channelId; - return ( - - ); - })} -
-
-
- )} - - {activeTab === 'composio' && ( -
-
-

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

-

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

-
- {showLocalComposioApiKeyBanner && ( - navigate('/settings/integrations#composio')} - /> - )} - {!showLocalComposioApiKeyBanner && ( -
- - -
- )} - {!showLocalComposioApiKeyBanner && - (composioSortedEntries.length > 0 ? ( -
- {composioSortedEntries.map(({ meta, connection }) => { - const allConns = composioConnectionsByToolkit?.get(meta.slug); - const activeCount = - allConns?.filter(c => deriveComposioState(c) === 'connected') - .length ?? 0; +
+ {channelDefs.map(def => { + const channelId = def.id as ChannelType; + const selected = + channelConnections.defaultMessagingChannel === channelId; return ( -
- setComposioModalToolkit(meta)} - onRetryGlobal={() => void refreshComposio()} - /> -
+ ); })}
- ) : ( -

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

- ))} -
- )} - - {activeTab === 'composio' && otherGroups.map(group => renderGroup(group))} - - {activeTab === 'skills' && ( -
- - -
- )} - - {activeTab === 'mcp' && ( -
- -
- +
-
- )} + )} - {activeTab === 'meetings' && ( -
- - -
- )} + {activeTab === 'composio' && ( +
+ {showLocalComposioApiKeyBanner && ( + handleTabChange('composio-key')} + /> + )} + {!showLocalComposioApiKeyBanner && ( +
+ + +
+ )} + {!showLocalComposioApiKeyBanner && + (composioSortedEntries.length > 0 ? ( +
+ {composioSortedEntries.map(({ meta, connection }) => { + const allConns = composioConnectionsByToolkit?.get(meta.slug); + const activeCount = + allConns?.filter(c => deriveComposioState(c) === 'connected') + .length ?? 0; + return ( +
+ setComposioModalToolkit(meta)} + onRetryGlobal={() => void refreshComposio()} + /> +
+ ); + })} +
+ ) : ( +

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

+ ))} +
+ )} - {/* Intelligence panels relocated from Settings. Wrapped in the - settings two-pane context so their headers hide the back - button (the sidebar provides navigation here). */} - {INTELLIGENCE_TABS.has(activeTab) && ( -
- - {activeTab === 'llm' && } - {activeTab === 'voice' && } - {activeTab === 'embeddings' && } - {activeTab === 'search' && } - -
- )} - - } -
-
+ {activeTab === 'composio' && otherGroups.map(group => renderGroup(group))} + + {activeTab === 'skills' && ( +
+ + +
+ )} + + {activeTab === 'mcp' && ( +
+ +
+ +
+
+ )} + + {activeTab === 'meetings' && ( +
+ + +
+ )} + + } +
+ + )} {channelModalDef && ( diff --git a/app/src/pages/__tests__/Brain.test.tsx b/app/src/pages/__tests__/Brain.test.tsx index 2fb6cc035..9105d49eb 100644 --- a/app/src/pages/__tests__/Brain.test.tsx +++ b/app/src/pages/__tests__/Brain.test.tsx @@ -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(, { initialEntries: [`/?tab=${tab}`] }); + }); + await waitFor(() => { + expect(screen.getByTestId(testId)).toBeInTheDocument(); + }); + }); }); diff --git a/app/src/pages/__tests__/Skills.channels-grid.test.tsx b/app/src/pages/__tests__/Skills.channels-grid.test.tsx index 73da06947..78df779d1 100644 --- a/app/src/pages/__tests__/Skills.channels-grid.test.tsx +++ b/app/src/pages/__tests__/Skills.channels-grid.test.tsx @@ -152,9 +152,7 @@ describe('Skills page — Channels grid', () => { fireEvent.click(screen.getByTestId('two-pane-nav-composio')); // The Composio tab owns the Integrations category filter. - const integrationsHeading = screen.getByRole('heading', { name: 'Composio Integrations' }); - const integrationsCard = integrationsHeading.closest('.rounded-2xl'); - expect(integrationsCard).not.toBeNull(); + const integrationsCard = screen.getByTestId('composio-integrations-card'); const filterTabs = within(integrationsCard as HTMLElement) .queryAllByRole('tab') .map(el => el.textContent?.trim()); diff --git a/app/src/pages/__tests__/Skills.composio-catalog.test.tsx b/app/src/pages/__tests__/Skills.composio-catalog.test.tsx index 2485ded9a..c47e0fbd7 100644 --- a/app/src/pages/__tests__/Skills.composio-catalog.test.tsx +++ b/app/src/pages/__tests__/Skills.composio-catalog.test.tsx @@ -86,7 +86,7 @@ describe('Skills page — Composio catalog fallback', () => { renderWithProviders(, { initialEntries: ['/connections'] }); openAppsTab(); - expect(screen.getByRole('heading', { name: 'Composio Integrations' })).toBeInTheDocument(); + expect(screen.getByTestId('composio-integrations-card')).toBeInTheDocument(); expect(screen.getByText('Discord')).toBeInTheDocument(); expect(screen.getByText('Google Calendar')).toBeInTheDocument(); expect(screen.getByText('Google Drive')).toBeInTheDocument(); @@ -102,10 +102,7 @@ describe('Skills page — Composio catalog fallback', () => { // Scope to the Integrations section so the assertion still catches a // missing Composio Zoom tile even though the Meeting bots card also // renders a "Zoom" entry on the same page. - const integrationsSection = screen - .getByRole('heading', { name: 'Composio Integrations' }) - .closest('.rounded-2xl'); - expect(integrationsSection).not.toBeNull(); + const integrationsSection = screen.getByTestId('composio-integrations-card'); expect(within(integrationsSection as HTMLElement).getByText('Zoom')).toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Other' })).not.toBeInTheDocument(); }); @@ -119,10 +116,7 @@ describe('Skills page — Composio catalog fallback', () => { expect(screen.getByText('Connections are showing stale status')).toBeInTheDocument(); expect(screen.getByText('Backend unavailable')).toBeInTheDocument(); - const integrationsSection = screen - .getByRole('heading', { name: 'Composio Integrations' }) - .closest('.rounded-2xl'); - expect(integrationsSection).not.toBeNull(); + const integrationsSection = screen.getByTestId('composio-integrations-card'); const gmailTile = within(integrationsSection as HTMLElement).getByRole('button', { name: /Gmail.*Status unavailable/i, }); @@ -142,10 +136,7 @@ describe('Skills page — Composio catalog fallback', () => { renderWithProviders(, { initialEntries: ['/connections'] }); openAppsTab(); - const integrationsSection = screen - .getByRole('heading', { name: 'Composio Integrations' }) - .closest('.rounded-2xl'); - expect(integrationsSection).not.toBeNull(); + const integrationsSection = screen.getByTestId('composio-integrations-card'); const gmailTile = within(integrationsSection as HTMLElement).getByRole('button', { name: /Gmail.*Auth expired.*Reconnect/i, }); @@ -177,10 +168,7 @@ describe('Skills page — Composio catalog fallback', () => { renderWithProviders(, { initialEntries: ['/connections'] }); openAppsTab(); - const integrationsSection = screen - .getByRole('heading', { name: 'Composio Integrations' }) - .closest('.rounded-2xl'); - expect(integrationsSection).not.toBeNull(); + const integrationsSection = screen.getByTestId('composio-integrations-card'); expect(within(integrationsSection as HTMLElement).getByText('2')).toBeInTheDocument(); }); @@ -197,10 +185,7 @@ describe('Skills page — Composio catalog fallback', () => { renderWithProviders(, { initialEntries: ['/connections'] }); openAppsTab(); - const integrationsSection = screen - .getByRole('heading', { name: 'Composio Integrations' }) - .closest('.rounded-2xl'); - expect(integrationsSection).not.toBeNull(); + const integrationsSection = screen.getByTestId('composio-integrations-card'); // No Preview badges anywhere in the integrations grid. The // badge carries a `data-testid` of the form // `composio-preview-badge-`; absence means we degraded @@ -221,10 +206,7 @@ describe('Skills page — Composio catalog fallback', () => { renderWithProviders(, { initialEntries: ['/connections'] }); openAppsTab(); - const integrationsSection = screen - .getByRole('heading', { name: 'Composio Integrations' }) - .closest('.rounded-2xl'); - expect(integrationsSection).not.toBeNull(); + const integrationsSection = screen.getByTestId('composio-integrations-card'); const zohoTile = within(integrationsSection as HTMLElement).getByRole('button', { name: /Zoho Mail.*Preview/i, }); diff --git a/app/src/pages/__tests__/Skills.intelligence-tabs.test.tsx b/app/src/pages/__tests__/Skills.intelligence-tabs.test.tsx new file mode 100644 index 000000000..0004e2d84 --- /dev/null +++ b/app/src/pages/__tests__/Skills.intelligence-tabs.test.tsx @@ -0,0 +1,84 @@ +import { screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import '../../test/mockDefaultSkillStatusHooks'; +import { renderWithProviders } from '../../test/test-utils'; +import Skills from '../Skills'; + +// The "API keys" group tabs (llm / voice / embeddings / search / composio-key) +// render relocated settings panels inside the Connections two-pane shell. Stub +// each so the per-tab branches in Skills are exercised without their deep trees. +vi.mock('../../components/settings/panels/AIPanel', () => ({ + default: () =>
, +})); +vi.mock('../../components/settings/panels/VoicePanel', () => ({ + default: () =>
, +})); +vi.mock('../../components/settings/panels/EmbeddingsPanel', () => ({ + default: () =>
, +})); +vi.mock('../../components/settings/panels/SearchPanel', () => ({ + default: () =>
, +})); +vi.mock('../../components/settings/panels/ComposioPanel', () => ({ + default: () =>
, +})); + +vi.mock('../../hooks/useChannelDefinitions', () => ({ + useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }), +})); +vi.mock('../../lib/skills/skillsApi', () => ({ + installSkill: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../../lib/skills/hooks', () => ({ + useAvailableSkills: () => ({ skills: [], loading: false, refresh: vi.fn() }), +})); +vi.mock('../../lib/composio/hooks', () => ({ + useComposioIntegrations: () => ({ + toolkits: [], + connectionByToolkit: new Map(), + connectionsByToolkit: new Map(), + refresh: vi.fn(), + loading: false, + error: null, + }), + useAgentReadyComposioToolkits: () => ({ + agentReady: new Set(), + loading: true, + error: null, + }), +})); +vi.mock('../../lib/coreState/store', async () => { + const actual = await vi.importActual( + '../../lib/coreState/store' + ); + return { ...actual, getCoreStateSnapshot: () => ({ snapshot: { sessionToken: 'jwt-abc' } }) }; +}); +vi.mock('../../utils/tauriCommands', async () => { + const actual = await vi.importActual( + '../../utils/tauriCommands' + ); + return { + ...actual, + openhumanComposioGetMode: vi.fn(async () => ({ + result: { mode: 'backend', api_key_set: true }, + logs: [], + })), + }; +}); + +describe('Skills page — API keys (intelligence) tabs', () => { + it.each([ + ['llm', 'skills-ai-panel'], + ['voice', 'skills-voice-panel'], + ['embeddings', 'skills-embeddings-panel'], + ['search', 'skills-search-panel'], + ['composio-key', 'skills-composio-panel'], + ])('renders the %s panel for ?tab=%s', async (tab, testId) => { + renderWithProviders(, { initialEntries: [`/connections?tab=${tab}`] }); + + await waitFor(() => { + expect(screen.getByTestId(testId)).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx b/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx index ca4dd6184..1f8d58eda 100644 --- a/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx +++ b/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx @@ -43,10 +43,7 @@ describe('Skills page — Gmail composio integration', () => { renderWithProviders(, { initialEntries: ['/connections'] }); fireEvent.click(screen.getByTestId('two-pane-nav-composio')); - const integrationsSection = screen - .getByRole('heading', { name: 'Composio Integrations' }) - .closest('.rounded-2xl'); - expect(integrationsSection).not.toBeNull(); + const integrationsSection = screen.getByTestId('composio-integrations-card'); expect(within(integrationsSection as HTMLElement).getByText('Gmail')).toBeInTheDocument(); expect(within(integrationsSection as HTMLElement).getByText('Connected')).toBeInTheDocument(); diff --git a/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx b/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx index 0fd07e9fa..d9eb701ee 100644 --- a/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx +++ b/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx @@ -39,7 +39,7 @@ describe('Skills page — Notion composio integration', () => { renderWithProviders(, { initialEntries: ['/connections'] }); fireEvent.click(screen.getByTestId('two-pane-nav-composio')); - expect(screen.getByRole('heading', { name: 'Composio Integrations' })).toBeInTheDocument(); + expect(screen.getByTestId('composio-integrations-card')).toBeInTheDocument(); const notionTile = screen.getByRole('button', { name: /Notion.*Connect/i }); expect(notionTile).toBeInTheDocument(); diff --git a/app/src/pages/onboarding/customWizardSteps.ts b/app/src/pages/onboarding/customWizardSteps.ts index 4427e1552..7880d0f15 100644 --- a/app/src/pages/onboarding/customWizardSteps.ts +++ b/app/src/pages/onboarding/customWizardSteps.ts @@ -29,7 +29,7 @@ export const CUSTOM_WIZARD_ROUTES: Record = { export const CUSTOM_WIZARD_SETTINGS_ROUTES: Record = { inference: '/settings/llm', voice: '/settings/voice', - oauth: '/settings/integrations#composio', + oauth: '/connections?tab=composio-key', search: '/settings/tools', embeddings: '/settings/embeddings', activity: '/settings/activity-level', diff --git a/app/test/playwright/specs/agent-review.spec.ts b/app/test/playwright/specs/agent-review.spec.ts index 6a0edf477..7504725c8 100644 --- a/app/test/playwright/specs/agent-review.spec.ts +++ b/app/test/playwright/specs/agent-review.spec.ts @@ -34,7 +34,6 @@ test.describe('Agent review - canonical onboarding + privacy flow', () => { await waitForAppReady(page); await expect(page.getByTestId('settings-privacy-panel')).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Privacy & Security' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Product Analytics' })).toBeVisible(); await expect(page.getByText('Share Product Analytics and Diagnostics')).toBeVisible(); }); diff --git a/app/test/playwright/specs/composio-triggers-flow.spec.ts b/app/test/playwright/specs/composio-triggers-flow.spec.ts index 35f7bd6ea..0dfbabbab 100644 --- a/app/test/playwright/specs/composio-triggers-flow.spec.ts +++ b/app/test/playwright/specs/composio-triggers-flow.spec.ts @@ -84,10 +84,8 @@ async function bootSkillsPage(page: Page, userId: string) { await dismissWalkthroughIfPresent(page); // Navigate to the Composio tab await page.getByTestId('two-pane-nav-composio').click(); - // Heading reads "Composio Integrations" (skills.integrations); the tab is "Apps" - await expect( - page.getByRole('heading', { name: 'Composio Integrations', exact: true }) - ).toBeVisible({ timeout: 20_000 }); + // Tab is "Apps"; the grid renders in the composio-integrations-card container. + await expect(page.getByTestId('composio-integrations-card')).toBeVisible({ timeout: 20_000 }); } async function openGmailManageModal(page: Page) { @@ -186,11 +184,9 @@ test.describe('Composio triggers flow', () => { await page.reload(); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - // Tab is "Apps"; heading reads "Composio Integrations" + // Tab is "Apps"; the grid renders in the composio-integrations-card container. await page.getByTestId('two-pane-nav-composio').click(); - await expect( - page.getByRole('heading', { name: 'Composio Integrations', exact: true }) - ).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId('composio-integrations-card')).toBeVisible({ timeout: 20_000 }); const dialog = await openGmailManageModal(page); await expect(dialog.getByTestId('trigger-toggles')).toBeVisible(); diff --git a/app/test/playwright/specs/connector-gmail-composio.spec.ts b/app/test/playwright/specs/connector-gmail-composio.spec.ts index 06b2bc2f5..9042e60a8 100644 --- a/app/test/playwright/specs/connector-gmail-composio.spec.ts +++ b/app/test/playwright/specs/connector-gmail-composio.spec.ts @@ -77,7 +77,7 @@ async function bootSkillsPage(page: Page, userId: string) { await dismissWalkthroughIfPresent(page); // Navigate to the Composio tab await page.getByTestId('two-pane-nav-composio').click(); - const heading = page.getByRole('heading', { name: 'Composio Integrations', exact: true }); + const heading = page.getByTestId('composio-integrations-card'); if (!(await heading.isVisible().catch(() => false))) { const connectionsButton = page.getByRole('button', { name: 'Connections' }); if (await connectionsButton.isVisible().catch(() => false)) { @@ -89,9 +89,7 @@ async function bootSkillsPage(page: Page, userId: string) { await dismissWalkthroughIfPresent(page); } } - await expect( - page.getByRole('heading', { name: 'Composio Integrations', exact: true }) - ).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId('composio-integrations-card')).toBeVisible({ timeout: 20_000 }); } async function reloadSkills(page: Page) { @@ -100,7 +98,7 @@ async function reloadSkills(page: Page) { async function ensureComposioSurface(page: Page) { // Navigate to /connections and click the Composio tab - const heading = page.getByRole('heading', { name: 'Composio Integrations', exact: true }); + const heading = page.getByTestId('composio-integrations-card'); for (let attempt = 0; attempt < 3; attempt++) { await page.evaluate(() => { window.location.hash = '/connections'; diff --git a/app/test/playwright/specs/connector-session-guard-matrix.spec.ts b/app/test/playwright/specs/connector-session-guard-matrix.spec.ts index 9a0b72961..5515792ba 100644 --- a/app/test/playwright/specs/connector-session-guard-matrix.spec.ts +++ b/app/test/playwright/specs/connector-session-guard-matrix.spec.ts @@ -74,9 +74,7 @@ async function bootSkills(page: Page, userId: string): Promise { await waitForAppReady(page); await dismissWalkthroughIfPresent(page); await page.getByTestId('two-pane-nav-composio').click(); - await expect( - page.getByRole('heading', { name: 'Composio Integrations', exact: true }) - ).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId('composio-integrations-card')).toBeVisible({ timeout: 20_000 }); } async function assertSessionAlive(page: Page): Promise { diff --git a/app/test/playwright/specs/cron-jobs-flow.spec.ts b/app/test/playwright/specs/cron-jobs-flow.spec.ts index 6e1c03b58..62b320812 100644 --- a/app/test/playwright/specs/cron-jobs-flow.spec.ts +++ b/app/test/playwright/specs/cron-jobs-flow.spec.ts @@ -7,9 +7,10 @@ const MORNING_BRIEFING = 'morning_briefing'; async function openCronJobsPanel(page: import('@playwright/test').Page): Promise { await page.goto('/#/settings/cron-jobs'); await waitForAppReady(page); - await expect(page.getByRole('heading', { name: 'Cron Jobs', exact: true })).toBeVisible(); - await expect(page.getByText('Scheduled Jobs').first()).toBeVisible(); + // Panel title dropped in the PanelPage migration; the panel test id and its + // Scheduled Jobs section confirm it mounted. await expect(page.getByTestId('cron-jobs-panel')).toBeVisible(); + await expect(page.getByText('Scheduled Jobs').first()).toBeVisible(); } test.describe('Cron jobs settings panel', () => { diff --git a/app/test/playwright/specs/gmail-flow.spec.ts b/app/test/playwright/specs/gmail-flow.spec.ts index 8c624729b..9f78b7175 100644 --- a/app/test/playwright/specs/gmail-flow.spec.ts +++ b/app/test/playwright/specs/gmail-flow.spec.ts @@ -70,10 +70,10 @@ async function bootSkillsPage(page: Page, userId: string) { }); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - // Tab is "Apps"; the h2 heading reads "Composio Integrations" (skills.integrations) + // Tab is "Apps"; the grid renders in the composio-integrations-card container. await page.getByTestId('two-pane-nav-composio').click(); - // Wait for the Apps tab grid to be visible — the h2 heading text is "Composio Integrations" - const heading = page.getByRole('heading', { name: 'Composio Integrations', exact: true }); + // Wait for the Apps tab grid container to be visible. + const heading = page.getByTestId('composio-integrations-card'); await expect(heading).toBeVisible({ timeout: 20_000 }); } @@ -141,11 +141,9 @@ test.describe('Gmail Integration Flows', () => { test('execute and disconnect routes do not blank the skills page', async ({ page }) => { await callCoreRpc('openhuman.composio_execute', { tool: ACTION, arguments: {} }); - // Tab is "Apps"; the h2 heading reads "Composio Integrations" (skills.integrations) + // Tab is "Apps"; the grid renders in the composio-integrations-card container. await page.getByTestId('two-pane-nav-composio').click(); - await expect( - page.getByRole('heading', { name: 'Composio Integrations', exact: true }) - ).toBeVisible(); + await expect(page.getByTestId('composio-integrations-card')).toBeVisible(); await callCoreRpc('openhuman.composio_delete_connection', { connection_id: CONNECTION_ID }); const requests = await getRequestLog(); diff --git a/app/test/playwright/specs/settings-account-preferences.spec.ts b/app/test/playwright/specs/settings-account-preferences.spec.ts index 8a29bfff7..9f2d4407e 100644 --- a/app/test/playwright/specs/settings-account-preferences.spec.ts +++ b/app/test/playwright/specs/settings-account-preferences.spec.ts @@ -34,7 +34,9 @@ test.describe('Settings - Account Preferences', () => { test('renders the account settings section route', async ({ page }) => { await gotoSettingsRoute(page, '/settings/account'); - await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible(); + // Panel titles were dropped in the PanelPage migration; assert the panel's + // stable test id instead of the old heading. + await expect(page.getByTestId('account-panel')).toBeVisible(); // The Account family surfaces its leaves via the sub-nav pill row above the // panel (the two-pane sidebar replaced the old section-hub list). await expect(page.getByTestId('settings-subnav-team')).toBeVisible(); @@ -51,7 +53,8 @@ test.describe('Settings - Account Preferences', () => { // whose sub-nav family surfaces recovery-phrase + wallet-balances. await gotoSettingsRoute(page, '/settings/crypto'); - await expect(page.getByRole('heading', { name: 'Wallet Balances' })).toBeVisible(); + // Panel titles were dropped in the PanelPage migration; the Wallet family is + // confirmed by its sub-nav leaves below. await expect(page.getByTestId('settings-subnav-recovery-phrase')).toBeVisible(); await expect(page.getByTestId('settings-subnav-wallet-balances')).toBeVisible(); }); @@ -102,7 +105,7 @@ test.describe('Settings - Account Preferences', () => { await gotoSettingsRoute(page, '/settings/privacy'); - await expect(page.getByRole('heading', { name: 'Privacy & Security' })).toBeVisible(); + await expect(page.getByTestId('settings-privacy-panel')).toBeVisible(); await expect(page.getByText('Share Product Analytics and Diagnostics')).toBeVisible(); // Toggle + confirm each setting sequentially. Clicking both back-to-back and diff --git a/app/test/playwright/specs/settings-advanced-config.spec.ts b/app/test/playwright/specs/settings-advanced-config.spec.ts index 242d33dfb..b2e35d2f5 100644 --- a/app/test/playwright/specs/settings-advanced-config.spec.ts +++ b/app/test/playwright/specs/settings-advanced-config.spec.ts @@ -59,7 +59,8 @@ test.describe('Settings - Advanced Config', () => { test('renders the developer options route and its advanced entries', async ({ page }) => { await gotoSettingsRoute(page, '/settings/developer-options'); - await expect(page.getByRole('heading', { name: 'Developer & Diagnostics' })).toBeVisible(); + // Panel title dropped in the PanelPage migration; the panel is confirmed by + // its diagnostics entries below. // Developer Options is debug-only now: user-facing sections (AI, Integrations…) // live on their section pages, so Developer Options surfaces diagnostics entries. // The two-pane sidebar may also surface these ids, so scope to the first match. @@ -177,7 +178,9 @@ test.describe('Settings - Advanced Config', () => { test('persists agent chat draft state to localStorage', async ({ page }) => { await gotoSettingsRoute(page, '/settings/agent-chat'); - await expect(page.getByText('Overrides')).toBeVisible(); + // The panel's description copy also contains the word "overrides", so scope + // to the section heading to avoid a strict-mode match on both. + await expect(page.getByRole('heading', { name: 'Overrides' })).toBeVisible(); await page.getByPlaceholder('gpt-4o').fill('gpt-4.1-mini'); await page.getByPlaceholder('0.7').fill('0.2'); @@ -202,7 +205,9 @@ test.describe('Settings - Advanced Config', () => { await expect(page.getByText('Local Model Debug').first()).toBeVisible(); await gotoSettingsRoute(page, '/settings/about'); - await expect(page.getByText('Software updates')).toBeVisible(); + // The About description copy also contains "software updates"; match the + // section label exactly to avoid a strict-mode violation. + await expect(page.getByText('Software updates', { exact: true })).toBeVisible(); // /settings/llm now redirects to the Connections page (LLM moved there). await gotoSettingsRoute(page, '/settings/llm'); diff --git a/app/test/playwright/specs/settings-channels-permissions.spec.ts b/app/test/playwright/specs/settings-channels-permissions.spec.ts index 5d74ba58c..815bf0128 100644 --- a/app/test/playwright/specs/settings-channels-permissions.spec.ts +++ b/app/test/playwright/specs/settings-channels-permissions.spec.ts @@ -50,7 +50,7 @@ test.describe('Settings - Channels & Permissions', () => { await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - await expect(page.getByRole('heading', { name: 'Privacy & Security' })).toBeVisible(); + await expect(page.getByTestId('settings-privacy-panel')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Product Analytics' })).toBeVisible(); await expect(page.getByText('Share Product Analytics and Diagnostics')).toBeVisible(); await expect(page.getByText('What leaves your computer')).toBeVisible(); diff --git a/app/test/playwright/specs/settings-dev-options.spec.ts b/app/test/playwright/specs/settings-dev-options.spec.ts index 42742b4d8..4f385c203 100644 --- a/app/test/playwright/specs/settings-dev-options.spec.ts +++ b/app/test/playwright/specs/settings-dev-options.spec.ts @@ -16,7 +16,7 @@ test.describe('Settings - Developer Options', () => { await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - await expect(page.getByText('Webhooks Debug')).toBeVisible(); + await expect(page.getByTestId('webhooks-debug-panel')).toBeVisible(); await expect(page.getByText('Registered Webhooks')).toBeVisible(); await expect(page.getByText('Captured Requests')).toBeVisible(); await expect(page.getByRole('button', { name: 'Refresh' }).first()).toBeVisible(); @@ -27,7 +27,7 @@ test.describe('Settings - Developer Options', () => { await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - await expect(page.getByText('Memory Debug')).toBeVisible(); + await expect(page.getByTestId('memory-debug-panel')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Documents', exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Namespaces', exact: true })).toBeVisible(); await expect(page.getByText('Query & Recall')).toBeVisible(); @@ -39,7 +39,8 @@ test.describe('Settings - Developer Options', () => { await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - await expect(page.getByText('Autocomplete Debug')).toBeVisible(); + // Panel title dropped in the PanelPage migration; the panel is confirmed by + // its Live Logs section below. await expect(page.getByText('Live Logs')).toBeVisible(); await expect(page.getByText(/No logs yet\.|\[runtime\]/)).toBeVisible(); }); diff --git a/app/test/playwright/specs/settings-leaf-workflows.spec.ts b/app/test/playwright/specs/settings-leaf-workflows.spec.ts index c432afc0e..c04b79b1e 100644 --- a/app/test/playwright/specs/settings-leaf-workflows.spec.ts +++ b/app/test/playwright/specs/settings-leaf-workflows.spec.ts @@ -64,7 +64,9 @@ test.describe('Settings leaf workflows', () => { }) => { await openSettings(page, 'pw-settings-appearance', '/settings/appearance'); - await expect(page.getByRole('heading', { name: 'Appearance' })).toBeVisible(); + // Panel title dropped in the PanelPage migration; the theme radios confirm + // the Appearance panel mounted. + await expect(page.getByRole('radio', { name: /Dark/ })).toBeVisible(); await page.getByRole('radio', { name: /Dark/ }).click(); const labelSwitch = page.getByRole('switch', { name: /Always show labels/ }); if ((await labelSwitch.getAttribute('aria-checked')) !== 'true') { @@ -94,7 +96,9 @@ test.describe('Settings leaf workflows', () => { }) => { await openSettings(page, 'pw-settings-embeddings', '/settings/embeddings'); - await expect(page.getByRole('heading', { name: 'Embeddings' })).toBeVisible(); + // Panel title dropped in the PanelPage migration; the provider radios confirm + // the Embeddings panel mounted. + await expect(page.getByRole('radio', { name: /Custom/i })).toBeVisible(); await page.getByRole('radio', { name: /Custom/i }).click(); await expect(page.getByRole('heading', { name: /Set up/i })).toBeVisible(); @@ -132,7 +136,9 @@ test.describe('Settings leaf workflows', () => { const agentId = `pw-researcher-${Date.now()}`; await openSettings(page, 'pw-settings-agent-new', '/settings/agents/new'); - await expect(page.getByRole('heading', { name: 'New agent' })).toBeVisible(); + // Page title dropped in the PanelPage migration; the Name field confirms the + // agent editor mounted. + await expect(page.getByRole('textbox', { name: 'Name' })).toBeVisible(); await page.getByRole('textbox', { name: 'Name' }).fill('Playwright Researcher'); await page.getByRole('textbox', { name: 'ID', exact: true }).fill(agentId); await page.getByLabel('Description').fill('Validates settings agent authoring in E2E.'); diff --git a/app/test/playwright/specs/skills-registry.spec.ts b/app/test/playwright/specs/skills-registry.spec.ts index 1003b1795..7a43cda94 100644 --- a/app/test/playwright/specs/skills-registry.spec.ts +++ b/app/test/playwright/specs/skills-registry.spec.ts @@ -37,9 +37,7 @@ test.describe('Skills registry flow', () => { await expect(page.getByTestId('two-pane-nav-channels')).toBeVisible(); await expect(page.getByTestId('two-pane-nav-mcp')).toBeVisible(); await page.getByTestId('two-pane-nav-composio').click(); - await expect( - page.getByRole('heading', { name: 'Composio Integrations', exact: true }) - ).toBeVisible(); + await expect(page.getByTestId('composio-integrations-card')).toBeVisible(); await expect( page.getByText(/Gmail|Notion|Telegram|GitHub|Google Drive/, { exact: false }).first() ).toBeVisible(); diff --git a/app/test/playwright/specs/webhooks-ingress-flow.spec.ts b/app/test/playwright/specs/webhooks-ingress-flow.spec.ts index 1752c7b74..fe267e024 100644 --- a/app/test/playwright/specs/webhooks-ingress-flow.spec.ts +++ b/app/test/playwright/specs/webhooks-ingress-flow.spec.ts @@ -69,8 +69,10 @@ test.describe('Webhooks ingress surface (stub-level)', () => { .poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 }) .toContain('/settings/webhooks-debug'); + // Panel title dropped in the PanelPage migration; assert the panel's stable + // test id, then check the section copy below. + await expect(page.getByTestId('webhooks-debug-panel')).toBeVisible(); const text = await page.locator('#root').innerText(); - expect(text.includes('Webhooks Debug')).toBe(true); expect(text.includes('Registered Webhooks')).toBe(true); expect(text.includes('Captured Requests')).toBe(true); expect(text.includes('No active registrations.')).toBe(true);