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