IA revamp: restructure app navigation and settings for everyday users (phased) (#3518)

This commit is contained in:
Cyrus Gray
2026-06-09 21:00:00 -07:00
committed by GitHub
parent 6a194a2be3
commit 04bec23405
104 changed files with 6002 additions and 1634 deletions
+33 -41
View File
@@ -4,18 +4,15 @@ import AppRoutesIOS from './AppRoutesIOS';
import DefaultRedirect from './components/DefaultRedirect';
import ProtectedRoute from './components/ProtectedRoute';
import PublicRoute from './components/PublicRoute';
import HumanPage from './features/human/HumanPage';
import { getIsMobile } from './lib/platform';
import Accounts from './pages/Accounts';
import Channels from './pages/Channels';
import Activity from './pages/Activity';
import Home from './pages/Home';
import Intelligence from './pages/Intelligence';
import Invites from './pages/Invites';
import Notifications from './pages/Notifications';
import Onboarding from './pages/onboarding/Onboarding';
import { PttOverlayPage } from './pages/PttOverlayPage';
import Rewards from './pages/Rewards';
import Routines from './pages/Routines';
import Settings from './pages/Settings';
import Skills from './pages/Skills';
import WebCallbackPage from './pages/WebCallbackPage';
@@ -65,33 +62,34 @@ const AppRoutes = () => {
}
/>
{/* Phase 6 — /human merged into /chat (Assistant surface).
Preserve the route for back-compat (deep links, iOS share sheets, etc.).
iOS AppRoutesIOS still serves /human natively — only desktop redirects. */}
<Route path="/human" element={<Navigate to="/chat" replace />} />
{/* Primary Activity surface — replaces /intelligence (Phase 3). */}
<Route
path="/human"
path="/activity"
element={
<ProtectedRoute requireAuth={true}>
<HumanPage />
<Activity />
</ProtectedRoute>
}
/>
<Route
path="/intelligence"
element={
<ProtectedRoute requireAuth={true}>
<Intelligence />
</ProtectedRoute>
}
/>
{/* Back-compat: /intelligence → /activity (preserves ?tab= deep links).
Deep links such as ?tab=memory or ?tab=agents still resolve but fall
back to the tasks tab in prod (dev-only tabs are gated inside Activity). */}
<Route path="/intelligence" element={<Navigate to="/activity" replace />} />
{/* Skills lives at /skills with its 4 sub-tabs (Composio / Channels /
MCP Servers / Runners). The scheduled-skills dashboard concept
composes INSIDE the Runners sub-tab, not as a separate top-level
page — the bottom-bar "Connections" entry has always pointed at
/skills to surface Composio integrations + MCP, and that muscle
memory is restored here.
{/* Connections page lives at /connections (Phase 2 rename from /skills).
The old /skills path is kept as a back-compat redirect so bookmarks
and deep links continue to work. `?tab=` query params are preserved
by Navigate (replace) so existing deep links still land on the right
sub-tab.
`/workflows/new` is the create-a-skill authoring page.
Order matters: keep `/workflows/new` before `/skills` so it wins the
prefix match. */}
Order matters: keep `/workflows/new` before `/connections` so it wins
the prefix match. */}
<Route
path="/workflows/new"
element={
@@ -111,7 +109,7 @@ const AppRoutes = () => {
/>
<Route
path="/skills"
path="/connections"
element={
<ProtectedRoute requireAuth={true}>
<Skills />
@@ -119,6 +117,9 @@ const AppRoutes = () => {
}
/>
{/* Back-compat: /skills → /connections (preserves ?tab= deep links). */}
<Route path="/skills" element={<Navigate to="/connections" replace />} />
{/* Unified chat = agent + connected web apps. Replaces the old
/conversations and /accounts routes. */}
<Route
@@ -130,14 +131,9 @@ const AppRoutes = () => {
}
/>
<Route
path="/channels"
element={
<ProtectedRoute requireAuth={true}>
<Channels />
</ProtectedRoute>
}
/>
{/* Back-compat: /channels was an orphaned standalone page; it now
redirects to the unified Connections page on the Messaging tab. */}
<Route path="/channels" element={<Navigate to="/connections?tab=messaging" replace />} />
<Route
path="/invites"
@@ -157,14 +153,10 @@ const AppRoutes = () => {
}
/>
<Route
path="/routines"
element={
<ProtectedRoute requireAuth={true}>
<Routines />
</ProtectedRoute>
}
/>
{/* Back-compat: /routines was an orphaned dead page (superseded by the
Cron Jobs settings panel). Redirect to Activity → Automations so
any surviving deep links land somewhere sensible. */}
<Route path="/routines" element={<Navigate to="/activity?tab=automations" replace />} />
<Route
path="/rewards"
@@ -175,9 +167,9 @@ const AppRoutes = () => {
}
/>
{/* Workflows moved onto the Intelligence page (its own tab). Keep the
{/* Workflows moved onto the Activity page (Automations tab). Keep the
old /workflows path working as a deep link into that tab. */}
<Route path="/workflows" element={<Navigate to="/intelligence?tab=workflows" replace />} />
<Route path="/workflows" element={<Navigate to="/activity?tab=automations" replace />} />
<Route path="/webhooks" element={<Navigate to="/settings/webhooks-triggers" replace />} />
+187 -128
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { AVATAR_MENU_ITEMS, NAV_TABS } from '../config/navConfig';
import { useT } from '../lib/i18n/I18nContext';
import { useCoreState } from '../providers/CoreStateProvider';
import { trackEvent } from '../services/analytics';
@@ -8,123 +9,105 @@ import { selectCompanionSessionActive } from '../store/companionSlice';
import { useAppSelector } from '../store/hooks';
import { selectUnreadCount } from '../store/notificationSlice';
import { isAccountsFullscreen } from '../utils/accountsFullscreen';
import { BILLING_DASHBOARD_URL } from '../utils/links';
import { isLocalSessionToken } from '../utils/localSession';
import { openUrl } from '../utils/openUrl';
import { resolveUserName } from '../utils/userName';
const makeTabs = (t: (key: string) => string) => [
{
id: 'home',
label: t('nav.home'),
path: '/home',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a2 2 0 01-2-2v-4a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2z"
/>
</svg>
),
},
{
id: 'human',
label: t('nav.human'),
path: '/human',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14c-4 0-7 2.5-7 6h14c0-3.5-3-6-7-6z"
/>
</svg>
),
},
{
id: 'chat',
label: t('nav.chat'),
path: '/chat',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
),
},
{
id: 'skills',
label: t('nav.connections'),
path: '/skills',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M14 10l-2 1m0 0l-2-1m2 1v2.5M20 7l-2 1m2-1l-2-1m2 1v2.5M14 4l-2-1-2 1M4 7l2-1M4 7l2 1M4 7v2.5M12 21l-2-1m2 1l2-1m-2 1v-2.5M6 18l-2-1v-2.5M18 18l2-1v-2.5"
/>
</svg>
),
},
{
id: 'intelligence',
label: t('nav.memory'),
path: '/intelligence',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
),
},
// Alerts/Notifications used to be its own bottom tab; moved into
// Settings Notifications since it's a low-traffic destination.
// The /notifications route still exists for deep links.
{
id: 'rewards',
label: t('nav.rewards'),
path: '/rewards',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M12 8v8m0-8l-3-3m3 3l3-3M8 14H6a2 2 0 01-2-2V7a2 2 0 012-2h2m8 9h2a2 2 0 002-2V7a2 2 0 00-2-2h-2M7 19h10"
/>
</svg>
),
},
{
id: 'settings',
label: t('nav.settings'),
path: '/settings',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
),
},
];
// ── SVG icons, keyed by tab id ────────────────────────────────────────────────
function TabIcon({ id }: { id: string }) {
switch (id) {
case 'home':
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a2 2 0 01-2-2v-4a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2z"
/>
</svg>
);
case 'human':
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14c-4 0-7 2.5-7 6h14c0-3.5-3-6-7-6z"
/>
</svg>
);
case 'chat':
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
);
case 'connections':
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M14 10l-2 1m0 0l-2-1m2 1v2.5M20 7l-2 1m2-1l-2-1m2 1v2.5M14 4l-2-1-2 1M4 7l2-1M4 7l2 1M4 7v2.5M12 21l-2-1m2 1l2-1m-2 1v-2.5M6 18l-2-1v-2.5M18 18l2-1v-2.5"
/>
</svg>
);
case 'activity':
// Reuse the Intelligence/memory lightbulb icon for the Activity tab
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
);
case 'settings':
return (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
default:
return null;
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const getInitials = (name: string): string => {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return 'OH';
return words
.slice(0, 2)
.map(word => word[0]?.toUpperCase() ?? '')
.join('');
};
// ── Component ─────────────────────────────────────────────────────────────────
const BottomTabBar = () => {
const { t } = useT();
@@ -133,6 +116,8 @@ const BottomTabBar = () => {
const { snapshot } = useCoreState();
const token = snapshot.sessionToken;
const [revealed, setRevealed] = useState(false);
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
const profileMenuRef = useRef<HTMLDivElement>(null);
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
const unreadCount = useAppSelector(state => selectUnreadCount(state.notifications.items));
@@ -142,7 +127,32 @@ const BottomTabBar = () => {
// so an absent theme branch can't crash the bar.
const tabBarLabels = useAppSelector(state => state.theme?.tabBarLabels ?? 'hover');
const labelsAlwaysVisible = tabBarLabels === 'always';
const tabs = useMemo(() => makeTabs(t), [t]);
const isLocalSession = isLocalSessionToken(token);
// The avatar button shows the signed-in user's initials.
const userInitials = getInitials(resolveUserName(snapshot.currentUser));
// Resolve translated labels for NAV_TABS once per render cycle.
const tabs = useMemo(() => NAV_TABS.map(tab => ({ ...tab, label: t(tab.labelKey) })), [t]);
useEffect(() => {
if (!profileMenuOpen) return;
const onPointerDown = (event: PointerEvent) => {
if (profileMenuRef.current?.contains(event.target as Node)) return;
setProfileMenuOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setProfileMenuOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [profileMenuOpen]);
const hiddenPaths = ['/', '/login'];
if (
@@ -189,6 +199,16 @@ const BottomTabBar = () => {
navigate(tab.path);
};
const handleAvatarMenuItemClick = (itemId: string, kind: string, target: string) => {
setProfileMenuOpen(false);
if (kind === 'openUrl') {
openUrl(target).catch(() => {});
} else {
navigate(target);
}
trackEvent('avatar_menu_item_click', { item_id: itemId });
};
return (
// pointer-events-none on the full-width shell so transparent areas (e.g.
// beside the centered nav pill) do not steal clicks from sticky footers
@@ -217,18 +237,10 @@ const BottomTabBar = () => {
const active = isActive(tab.path);
const showBadge = tab.id === 'notifications' && unreadCount > 0;
const showCompanionDot = tab.id === 'settings' && companionActive;
// data-walkthrough attributes for the Joyride walkthrough steps.
// Maps tab ids to their walkthrough target names.
const walkthroughAttr: Record<string, string> = {
chat: 'tab-chat',
skills: 'tab-skills',
notifications: 'tab-notifications',
settings: 'tab-settings',
};
return (
<button
key={tab.id}
data-walkthrough={walkthroughAttr[tab.id]}
data-walkthrough={tab.walkthroughAttr}
onClick={() => handleTabClick(tab, active)}
className={`group relative flex items-center px-2 py-2 rounded-sm text-sm transition-colors duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] cursor-pointer ${
active
@@ -241,7 +253,7 @@ const BottomTabBar = () => {
: tab.label
}>
<span className="relative inline-flex flex-shrink-0">
{tab.icon}
<TabIcon id={tab.id} />
{showBadge && (
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] px-1 rounded-full bg-coral-500 text-[9px] font-bold text-white flex items-center justify-center leading-none">
{unreadCount > 9 ? '9+' : unreadCount}
@@ -262,6 +274,53 @@ const BottomTabBar = () => {
</button>
);
})}
<div
className="relative ml-1 border-l border-stone-300 pl-1 dark:border-neutral-700"
ref={profileMenuRef}>
<button
type="button"
onClick={() => setProfileMenuOpen(open => !open)}
className={`relative flex h-9 w-9 items-center justify-center rounded-sm transition-colors duration-300 cursor-pointer ${
profileMenuOpen
? 'bg-white text-stone-900 shadow-sm dark:bg-neutral-800 dark:text-neutral-100'
: 'bg-transparent text-stone-500 hover:bg-stone-300/50 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200'
}`}
aria-haspopup="menu"
aria-expanded={profileMenuOpen}
aria-label={t('nav.avatarMenu.account')}
title={t('nav.avatarMenu.account')}>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary-500 text-[10px] font-semibold leading-none text-white">
{userInitials}
</span>
</button>
{profileMenuOpen && (
<div
role="menu"
aria-label={t('nav.avatarMenu.account')}
className="absolute bottom-full right-0 mb-2 w-56 overflow-hidden rounded-sm border border-stone-300 bg-white shadow-soft dark:border-neutral-700 dark:bg-neutral-900">
<div className="p-1">
{AVATAR_MENU_ITEMS.filter(item => !item.cloudOnly || !isLocalSession).map(
item => {
// Billing target is resolved from the canonical constant rather than the
// data-file placeholder so it stays in sync with SettingsHome.
const target = item.id === 'billing' ? BILLING_DASHBOARD_URL : item.target;
return (
<button
key={item.id}
type="button"
role="menuitem"
onClick={() => handleAvatarMenuItemClick(item.id, item.kind, target)}
className="flex w-full items-center rounded-sm px-2 py-2 text-left text-sm text-stone-700 transition-colors hover:bg-stone-100 dark:text-neutral-200 dark:hover:bg-neutral-800">
{t(item.labelKey)}
</button>
);
}
)}
</div>
</div>
)}
</div>
</nav>
</div>
</div>
@@ -1,10 +1,13 @@
/**
* Tests for BottomTabBar — verifies that:
* - the tab bar renders when the user has a session token and is on a non-hidden path
* - the walkthroughAttr mapping (line 222) is exercised by rendering the tabs
* - the tab bar is hidden on '/' and '/login' paths
* - 5 tabs are rendered (no Rewards tab, no Human tab), Activity label is present
* - Assistant tab is present (was "Chat", id stays 'chat', label now 'Assistant')
* - Walkthrough attributes reflect the new ids (tab-connections, tab-activity)
* - Avatar menu opens and shows Account / Billing / Rewards / Invites / Wallet
* - Clicking an avatar menu item navigates or opens URL
* - The bar is hidden on '/' and '/login' paths
*
* [#1123] Covers the walkthroughAttr object added for the Joyride walkthrough.
* Updated for IA Phase 6: Human tab removed; Chat renamed to Assistant.
*/
import { configureStore } from '@reduxjs/toolkit';
import { fireEvent, render, screen } from '@testing-library/react';
@@ -13,6 +16,7 @@ import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import accountsReducer from '../../store/accountsSlice';
import agentProfileReducer, { setAgentProfilesFromResponse } from '../../store/agentProfileSlice';
import companionReducer from '../../store/companionSlice';
import notificationReducer from '../../store/notificationSlice';
import BottomTabBar from '../BottomTabBar';
@@ -21,6 +25,15 @@ import BottomTabBar from '../BottomTabBar';
vi.mock('../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() }));
const agentProfilesApiMock = vi.hoisted(() => ({
list: vi.fn(),
select: vi.fn(),
upsert: vi.fn(),
delete: vi.fn(),
}));
vi.mock('../../services/api/agentProfilesApi', () => ({ agentProfilesApi: agentProfilesApiMock }));
vi.mock('../../utils/config', async importOriginal => {
const actual = await importOriginal<typeof import('../../utils/config')>();
return { ...actual, APP_ENVIRONMENT: 'development' };
@@ -29,20 +42,53 @@ vi.mock('../../utils/config', async importOriginal => {
vi.mock('../../utils/accountsFullscreen', () => ({ isAccountsFullscreen: vi.fn(() => false) }));
vi.mock('../../services/analytics', () => ({ trackEvent: vi.fn() }));
// Mock openUrl so tests don't try to open real URLs
vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn().mockResolvedValue(undefined) }));
// ── Helpers ────────────────────────────────────────────────────────────────
interface BuildStoreOpts {
companionSessionActive?: boolean;
}
const testProfiles = {
activeProfileId: 'planner',
profiles: [
{
id: 'default',
name: 'Orchestrator',
description: 'Default agent',
agentId: 'orchestrator',
builtIn: true,
},
{
id: 'planner',
name: 'Planner',
description: 'Plans multi-step work',
agentId: 'planner',
builtIn: true,
avatarUrl: 'https://example.com/planner.png',
},
{
id: 'research',
name: 'Research',
description: 'Finds and summarizes sources',
agentId: 'research',
builtIn: true,
},
],
};
function buildStore(opts: BuildStoreOpts = {}) {
const store = configureStore({
reducer: {
accounts: accountsReducer,
notifications: notificationReducer,
companion: companionReducer,
agentProfiles: agentProfileReducer,
},
});
store.dispatch(setAgentProfilesFromResponse(testProfiles));
if (opts.companionSessionActive) {
store.dispatch({
type: 'companion/setSessionActive',
@@ -56,6 +102,7 @@ interface RenderOpts {
hasToken?: boolean;
companionSessionActive?: boolean;
tokenValue?: string;
currentUser?: unknown;
}
async function renderBottomTabBar(pathname = '/home', opts: RenderOpts | boolean = {}) {
@@ -68,7 +115,7 @@ async function renderBottomTabBar(pathname = '/home', opts: RenderOpts | boolean
snapshot: {
sessionToken: hasToken ? tokenValue : null,
auth: { isAuthenticated: true, userId: 'u1', user: null, profileId: null },
currentUser: null,
currentUser: resolved.currentUser ?? null,
onboardingCompleted: true,
chatOnboardingCompleted: true,
analyticsEnabled: false,
@@ -106,19 +153,50 @@ async function renderBottomTabBar(pathname = '/home', opts: RenderOpts | boolean
describe('BottomTabBar', () => {
beforeEach(() => {
vi.clearAllMocks();
agentProfilesApiMock.select.mockResolvedValue(testProfiles);
});
// [#1123] Covers line 222 — walkthroughAttr object created per-tab inside .map()
it('renders navigation tabs with data-walkthrough attributes when session is active', async () => {
it('renders exactly 5 tab buttons (Phase 6: Human merged into Assistant)', async () => {
await renderBottomTabBar('/home');
// Query only buttons inside <nav> to exclude the avatar button
const nav = document.querySelector('nav');
const navButtons = nav?.querySelectorAll('button:not([aria-haspopup])');
expect(navButtons).toHaveLength(5);
});
// The Home tab is always visible and has no walkthrough attr (not in the map)
expect(screen.getByRole('button', { name: 'Home' })).toBeInTheDocument();
it('does NOT render a Rewards tab', async () => {
await renderBottomTabBar('/home');
expect(screen.queryByRole('button', { name: 'Rewards' })).toBeNull();
});
// Chat tab has data-walkthrough="tab-chat" (from walkthroughAttr map)
const chatBtn = screen.getByRole('button', { name: 'Chat' });
expect(chatBtn).toBeInTheDocument();
expect(chatBtn).toHaveAttribute('data-walkthrough', 'tab-chat');
it('does NOT render a Human tab (Phase 6: merged into Assistant)', async () => {
await renderBottomTabBar('/home');
expect(screen.queryByRole('button', { name: 'Human' })).toBeNull();
});
it('renders the Activity tab', async () => {
await renderBottomTabBar('/home');
expect(screen.getByRole('button', { name: 'Activity' })).toBeInTheDocument();
});
it('renders the Assistant tab (was Chat, Phase 6 rename)', async () => {
await renderBottomTabBar('/home');
const assistantBtn = screen.getByRole('button', { name: 'Assistant' });
expect(assistantBtn).toBeInTheDocument();
expect(assistantBtn).toHaveAttribute('data-walkthrough', 'tab-chat');
});
it('renders the Connections tab with data-walkthrough="tab-connections"', async () => {
await renderBottomTabBar('/home');
const connectionsBtn = screen.getByRole('button', { name: 'Connections' });
expect(connectionsBtn).toBeInTheDocument();
expect(connectionsBtn).toHaveAttribute('data-walkthrough', 'tab-connections');
});
it('renders Activity tab with data-walkthrough="tab-activity"', async () => {
await renderBottomTabBar('/home');
const activityBtn = screen.getByRole('button', { name: 'Activity' });
expect(activityBtn).toHaveAttribute('data-walkthrough', 'tab-activity');
});
it('renders Settings tab with data-walkthrough="tab-settings"', async () => {
@@ -132,11 +210,6 @@ describe('BottomTabBar', () => {
expect(container.firstChild).toBeNull();
});
it('still shows the Rewards tab for local sessions', async () => {
await renderBottomTabBar('/home', { tokenValue: 'header.payload.local' });
expect(screen.getByRole('button', { name: 'Rewards' })).toBeInTheDocument();
});
it('renders the pulsing companion dot on the Settings tab when a session is active', async () => {
const { container } = await renderBottomTabBar('/home', { companionSessionActive: true });
const settingsBtn = screen.getByRole('button', { name: 'Settings' });
@@ -164,7 +237,8 @@ describe('BottomTabBar', () => {
const { trackEvent } = await import('../../services/analytics');
await renderBottomTabBar('/home');
fireEvent.click(screen.getByRole('button', { name: 'Chat' }));
// Tab id is still 'chat' (back-compat) even though label is now 'Assistant'.
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
expect(trackEvent).toHaveBeenCalledWith('tab_bar_change', {
from_tab: 'home',
@@ -182,4 +256,65 @@ describe('BottomTabBar', () => {
expect(trackEvent).not.toHaveBeenCalled();
});
it('renders the avatar button with the signed-in user initials', async () => {
await renderBottomTabBar('/home', { currentUser: { firstName: 'Ada', lastName: 'Lovelace' } });
const avatar = screen.getByRole('button', { name: 'Account' });
expect(avatar).toHaveTextContent('AL');
});
it('falls back to a generic initial when no user is present', async () => {
await renderBottomTabBar('/home', { currentUser: null });
expect(screen.getByRole('button', { name: 'Account' })).toHaveTextContent('U');
});
it('avatar menu shows Account, Billing, Rewards, Invites, and Wallet items', async () => {
await renderBottomTabBar('/home');
fireEvent.click(screen.getByRole('button', { name: 'Account' }));
const menu = screen.getByRole('menu', { name: 'Account' });
const menuItems = menu.querySelectorAll('[role="menuitem"]');
const labels = Array.from(menuItems).map(el => el.textContent?.trim());
expect(labels).toContain('Account');
expect(labels).toContain('Billing');
expect(labels).toContain('Rewards');
expect(labels).toContain('Invite a friend');
expect(labels).toContain('Wallet');
});
it('clicking Account in avatar menu closes the menu', async () => {
await renderBottomTabBar('/home');
fireEvent.click(screen.getByRole('button', { name: 'Account' }));
expect(screen.getByRole('menu', { name: 'Account' })).toBeInTheDocument();
const accountItem = screen.getByRole('menuitem', { name: 'Account' });
fireEvent.click(accountItem);
// Menu should close after click
expect(screen.queryByRole('menu', { name: 'Account' })).toBeNull();
});
it('avatar menu does not show cloud-only items for local session', async () => {
// A local session token contains the literal string 'local'
await renderBottomTabBar('/home', { tokenValue: 'header.payload.local' });
fireEvent.click(screen.getByRole('button', { name: 'Account' }));
const menu = screen.getByRole('menu', { name: 'Account' });
const menuItems = menu.querySelectorAll('[role="menuitem"]');
const labels = Array.from(menuItems).map(el => el.textContent?.trim());
// Account and Wallet are always shown
expect(labels).toContain('Account');
expect(labels).toContain('Wallet');
// Cloud-only items should not appear for local sessions
expect(labels).not.toContain('Billing');
expect(labels).not.toContain('Rewards');
expect(labels).not.toContain('Invite a friend');
});
});
@@ -110,7 +110,7 @@ export default function RewardsCommunityTab({
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<button
onClick={() => navigate('/skills')}
onClick={() => navigate('/connections')}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-white dark:bg-neutral-900 px-4 py-3 text-sm font-semibold text-primary-700 dark:text-primary-300 shadow-lg transition-transform active:scale-[0.98]">
<svg
className="w-4 h-4"
+336 -195
View File
@@ -1,19 +1,15 @@
import type { ReactNode } from 'react';
import { useDeveloperMode } from '../../hooks/useDeveloperMode';
import { useT } from '../../lib/i18n/I18nContext';
import { useCoreState } from '../../providers/CoreStateProvider';
import { BILLING_DASHBOARD_URL } from '../../utils/links';
import { isLocalSessionToken } from '../../utils/localSession';
import { openUrl } from '../../utils/openUrl';
import LanguageSelect from '../LanguageSelect';
import SettingsHeader from './components/SettingsHeader';
import SettingsMenuItem from './components/SettingsMenuItem';
import { useSettingsNavigation } from './hooks/useSettingsNavigation';
interface SettingsSection {
label: string;
items: SettingsItem[];
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface SettingsItem {
id: string;
@@ -25,190 +21,313 @@ interface SettingsItem {
rightElement?: ReactNode;
}
interface SettingsGroup {
/** Stable identifier for testing and key prop */
id: string;
/** i18n label shown above the card */
label: string;
items: SettingsItem[];
}
// ---------------------------------------------------------------------------
// Icon helpers (inline SVG kept as constants to avoid duplication)
// ---------------------------------------------------------------------------
const AccountIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
);
const LanguageIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
/>
</svg>
);
const AppearanceIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"
/>
</svg>
);
const DevicesIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
);
const PersonalityIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
);
const MascotIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 21a9 9 0 100-18 9 9 0 000 18zM9 10h.01M15 10h.01M9.5 15c.83.67 1.67 1 2.5 1s1.67-.33 2.5-1"
/>
</svg>
);
const PrivacyIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
);
const NotificationsIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
);
const DeveloperIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
/>
</svg>
);
const AboutIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
const DataSyncIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
);
// ---------------------------------------------------------------------------
// Group header (visual separator label above each settings card)
// ---------------------------------------------------------------------------
const GroupHeader = ({ label }: { label: string }) =>
label ? (
<div className="px-1 pt-5 pb-1">
<span className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{label}
</span>
</div>
) : (
// Empty label → a plain divider (the doc places Developer & Diagnostics and
// About after a divider, not under their own section headers).
<div className="mx-1 mt-6 mb-2 border-t border-stone-200 dark:border-neutral-800" />
);
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
const SettingsHome = () => {
const { navigateToSettings } = useSettingsNavigation();
const { t } = useT();
const { snapshot } = useCoreState();
const isLocalSession = isLocalSessionToken(snapshot.sessionToken);
const developerMode = useDeveloperMode();
const settingsSections: SettingsSection[] = [
{
label: t('settings.general'),
items: [
{
id: 'account',
title: t('settings.account'),
description: t('settings.accountDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
),
onClick: () => navigateToSettings('account'),
},
// Alerts (inbox) + Notifications (preferences) now live together under
// the Advanced → Notifications hub (see DeveloperOptionsPanel).
{
id: 'devices',
title: 'Devices',
description: 'Pair iOS phones with this OpenHuman',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
),
onClick: () => navigateToSettings('devices'),
},
{
id: 'language',
title: t('settings.language'),
description: t('settings.languageDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
/>
</svg>
),
rightElement: <LanguageSelect ariaLabel={t('settings.language')} />,
},
{
id: 'appearance',
title: t('settings.appearance.title'),
description: t('settings.appearance.menuDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"
/>
</svg>
),
onClick: () => navigateToSettings('appearance'),
},
{
id: 'agents-settings',
title: t('settings.agentsSection.title'),
description: t('settings.agentsSection.menuDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 7h10a2 2 0 012 2v6a2 2 0 01-2 2H7a2 2 0 01-2-2V9a2 2 0 012-2zm2 4h.01M15 11h.01M9.5 15h5"
/>
</svg>
),
onClick: () => navigateToSettings('agents-settings'),
},
{
id: 'crypto',
title: t('settings.cryptoSection.title'),
description: t('settings.cryptoSection.menuDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V6m0 10c-1.11 0-2.08-.402-2.599-1M12 16v2m0-12a9 9 0 100 18 9 9 0 000-18z"
/>
</svg>
),
onClick: () => navigateToSettings('crypto'),
},
{
id: 'mascot',
title: t('settings.mascot.menuTitle'),
description: t('settings.mascot.menuDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 21a9 9 0 100-18 9 9 0 000 18zM9 10h.01M15 10h.01M9.5 15c.83.67 1.67 1 2.5 1s1.67-.33 2.5-1"
/>
</svg>
),
onClick: () => navigateToSettings('mascot'),
},
],
},
// Features tile (Screen Awareness / Messaging Channels / Notifications /
// Tools) used to live here. Everything under it moved into Advanced
// (DeveloperOptionsPanel), so the section is gone from the home menu.
// Billing & Rewards requires a backend-authenticated session.
// Hidden in local/offline mode — no auth headers are sent and the
// billing dashboard would not recognise the session.
...(!isLocalSession
? [
{
label: t('settings.billingAndRewards'),
items: [
{
id: 'billing',
title: t('settings.billingUsage'),
description: t('settings.billingUsageDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H5a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
),
onClick: () => {
openUrl(BILLING_DASHBOARD_URL).catch(() => {});
},
},
],
} satisfies SettingsSection,
]
: []),
{
label: t('settings.advanced'),
items: [
{
id: 'developer-options',
title: t('settings.developerOptions'),
description: t('settings.developerOptionsDesc'),
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
/>
</svg>
),
onClick: () => navigateToSettings('developer-options'),
},
],
},
// --- 👤 Account group ---
const accountGroup: SettingsGroup = {
id: 'account',
label: t('settings.groups.account'),
items: [
{
// The Account row opens the account hub (recovery phrase, team,
// connections, privacy, sign-out) — named after what it actually holds.
id: 'profile',
title: t('pages.settings.accountSection.title'),
description: t('pages.settings.accountSection.description'),
icon: AccountIcon,
onClick: () => navigateToSettings('account'),
},
{
id: 'language',
title: t('settings.language'),
description: t('settings.languageDesc'),
icon: LanguageIcon,
rightElement: <LanguageSelect ariaLabel={t('settings.language')} />,
},
{
id: 'appearance',
title: t('settings.appearance.title'),
description: t('settings.appearance.menuDesc'),
icon: AppearanceIcon,
onClick: () => navigateToSettings('appearance'),
},
{
id: 'devices',
title: t('settings.account.devices'),
description: t('settings.account.devicesDesc'),
icon: DevicesIcon,
onClick: () => navigateToSettings('devices'),
},
{
id: 'data-sync',
title: t('settings.dataSync.title'),
description: t('settings.dataSync.menuDesc'),
icon: DataSyncIcon,
onClick: () => navigateToSettings('memory-sync'),
},
],
};
// --- 🤖 Assistant group ---
const assistantGroup: SettingsGroup = {
id: 'assistant',
label: t('settings.groups.assistant'),
items: [
{
id: 'persona',
title: t('settings.assistant.personality'),
description: t('settings.assistant.personalityDesc'),
icon: PersonalityIcon,
onClick: () => navigateToSettings('persona'),
},
{
id: 'mascot',
title: t('settings.assistant.faceMascot'),
description: t('settings.assistant.faceMascotDesc'),
icon: MascotIcon,
onClick: () => navigateToSettings('mascot'),
},
],
};
// --- 🔒 Privacy group (Security + Approvals moved to Developer & Diagnostics) ---
const privacySecurityGroup: SettingsGroup = {
id: 'privacy-security',
label: t('settings.privacySecurity.privacy'),
items: [
{
id: 'privacy',
title: t('settings.privacySecurity.privacy'),
description: t('settings.privacySecurity.privacyDesc'),
icon: PrivacyIcon,
onClick: () => navigateToSettings('privacy'),
},
],
};
// --- 🔔 Notifications group ---
const notificationsGroup: SettingsGroup = {
id: 'notifications',
label: t('settings.groups.notifications'),
items: [
{
id: 'notifications-hub',
title: t('settings.notifications.menuTitle'),
description: t('settings.notifications.menuDesc'),
icon: NotificationsIcon,
onClick: () => navigateToSettings('notifications-hub'),
},
],
};
// --- ️ About group (always visible; no section header — just a divider) ---
const aboutGroup: SettingsGroup = {
id: 'about',
label: '',
items: [
{
id: 'about',
title: t('settings.about'),
description: t('settings.aboutDesc'),
icon: AboutIcon,
onClick: () => navigateToSettings('about'),
},
],
};
// --- Always-visible groups ---
const visibleGroups: SettingsGroup[] = [
accountGroup,
assistantGroup,
privacySecurityGroup,
notificationsGroup,
];
// Log Out and Clear App Data now live on the Account page (Settings → Account)
// alongside the recovery phrase, team, privacy, and migration entries.
// Billing / Rewards / Wallet are NOT in Settings — per the design doc they
// live in the avatar menu (monetisation out of the settings tree).
// --- Developer & Diagnostics (gated) ---
// The Developer & Diagnostics entry is hidden when developer mode is off.
// About is always accessible — that's where the toggle lives (chicken-and-egg).
// No section header — it sits after a divider, then About (per the doc).
const developerGroup: SettingsGroup | null = developerMode
? {
id: 'developer',
label: '',
items: [
{
id: 'developer-options',
title: t('settings.developerDiagnostics'),
description: t('settings.developerDiagnosticsDesc'),
icon: DeveloperIcon,
onClick: () => navigateToSettings('developer-options'),
},
],
}
: null;
// The layman groups (Account / Assistant / Privacy / Notifications) render as a
// single flat card with no section subheadings. Developer & Diagnostics (when
// on) and About sit after a divider, each in their own card.
const laymanItems: SettingsItem[] = visibleGroups.flatMap(group => group.items);
const trailingGroups: SettingsGroup[] = [...(developerGroup ? [developerGroup] : []), aboutGroup];
return (
<div className="z-10 relative">
@@ -216,12 +335,12 @@ const SettingsHome = () => {
<SettingsHeader />
</div>
<div>
{/* Flat list — group titles removed for clarity. Destructive
actions (Log Out, Clear App Data) now live on the Account page. */}
{(() => {
const flatItems = settingsSections.flatMap(s => s.items);
return flatItems.map((item, index) => (
<div className="px-4 pb-5">
{/* Merged layman card — no Account/Assistant/… subheadings. */}
<div
data-testid="settings-group-main"
className="rounded-3xl overflow-hidden border border-stone-200 dark:border-neutral-800">
{laymanItems.map((item, index) => (
<SettingsMenuItem
key={item.id}
icon={item.icon}
@@ -231,11 +350,33 @@ const SettingsHome = () => {
testId={`settings-nav-${item.id}`}
dangerous={item.dangerous}
isFirst={index === 0}
isLast={index === flatItems.length - 1}
isLast={index === laymanItems.length - 1}
rightElement={item.rightElement}
/>
));
})()}
))}
</div>
{trailingGroups.map(group => (
<div key={group.id} data-testid={`settings-group-${group.id}`}>
<GroupHeader label={group.label} />
<div className="rounded-3xl overflow-hidden border border-stone-200 dark:border-neutral-800">
{group.items.map((item, index) => (
<SettingsMenuItem
key={item.id}
icon={item.icon}
title={item.title}
description={item.description}
onClick={item.onClick}
testId={`settings-nav-${item.id}`}
dangerous={item.dangerous}
isFirst={index === 0}
isLast={index === group.items.length - 1}
rightElement={item.rightElement}
/>
))}
</div>
</div>
))}
</div>
</div>
);
@@ -8,12 +8,34 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { I18nProvider } from '../../../lib/i18n/I18nContext';
import type { Locale } from '../../../lib/i18n/types';
import localeReducer from '../../../store/localeSlice';
import themeReducer, {
type AgentMessageViewMode,
type FontSize,
type TabBarLabels,
type ThemeMode,
} from '../../../store/themeSlice';
import SettingsHome from '../SettingsHome';
function makeTestStore(locale: Locale = 'en') {
// `useDeveloperMode` combines IS_DEV || developerMode. In tests IS_DEV is
// true (Vite test mode), so mock the hook to control the gate explicitly.
const devModeHoisted = vi.hoisted(() => ({ value: false }));
vi.mock('../../../hooks/useDeveloperMode', () => ({
useDeveloperMode: () => devModeHoisted.value,
}));
function makeTestStore(locale: Locale = 'en', developerMode = false) {
return configureStore({
reducer: { locale: localeReducer },
preloadedState: { locale: { current: locale } },
reducer: { locale: localeReducer, theme: themeReducer },
preloadedState: {
locale: { current: locale },
theme: {
mode: 'system' as ThemeMode,
tabBarLabels: 'hover' as TabBarLabels,
fontSize: 'medium' as FontSize,
agentMessageViewMode: 'bubbles' as AgentMessageViewMode,
developerMode,
},
},
});
}
@@ -59,7 +81,10 @@ vi.mock('../../walkthrough/AppWalkthrough', () => ({ resetWalkthrough: vi.fn() }
// --- helpers ---
function renderSettingsHome({ locale = 'en', withI18n = false } = {}) {
function renderSettingsHome({ locale = 'en', withI18n = false, developerMode = false } = {}) {
// Set the mocked hook value before rendering.
devModeHoisted.value = developerMode;
const content = withI18n ? (
<I18nProvider>
<SettingsHome />
@@ -69,7 +94,7 @@ function renderSettingsHome({ locale = 'en', withI18n = false } = {}) {
);
return render(
<Provider store={makeTestStore(locale as Locale)}>
<Provider store={makeTestStore(locale as Locale, developerMode)}>
<MemoryRouter>{content}</MemoryRouter>
</Provider>
);
@@ -80,30 +105,84 @@ function renderSettingsHome({ locale = 'en', withI18n = false } = {}) {
describe('SettingsHome', () => {
beforeEach(() => {
vi.clearAllMocks();
devModeHoisted.value = false;
});
describe('flat menu', () => {
// Section headers ("General", "Features & AI", "Billing & Rewards",
// "Support", "Danger Zone") were intentionally removed — the menu is
// now a single flat list to reduce visual noise.
it.each(['General', 'Features & AI', 'Billing & Rewards', 'Support', 'Danger Zone'])(
'does not render section header: %s',
label => {
renderSettingsHome();
expect(screen.queryByText(label)).not.toBeInTheDocument();
}
);
it('renders the core menu items in a single list', () => {
describe('layman groups structure', () => {
it('renders the merged layman card and the About container', () => {
renderSettingsHome();
expect(screen.getByText('Account')).toBeInTheDocument();
expect(screen.getByText('Billing & Usage')).toBeInTheDocument();
expect(screen.getByText('Advanced')).toBeInTheDocument();
expect(screen.getByTestId('settings-nav-account')).toBeInTheDocument();
// The layman groups (Account/Assistant/Privacy/Notifications) merge into a
// single card with no subheadings; About keeps its own container.
expect(screen.getByTestId('settings-group-main')).toBeInTheDocument();
expect(screen.getByTestId('settings-group-about')).toBeInTheDocument();
expect(screen.queryByTestId('settings-group-account')).not.toBeInTheDocument();
expect(screen.queryByTestId('settings-group-assistant')).not.toBeInTheDocument();
});
it('no longer renders Alerts / Notifications on the home screen', () => {
// Both moved into the Advanced → Notifications hub.
it('renders the Account group items', () => {
renderSettingsHome();
// Account group: Account (hub), Language, Appearance, Devices, Data Sync.
// Team & members and Data & migration moved out (dev / removed).
expect(screen.getByTestId('settings-nav-profile')).toBeInTheDocument();
expect(screen.getByTestId('settings-nav-language')).toBeInTheDocument();
expect(screen.getByTestId('settings-nav-appearance')).toBeInTheDocument();
expect(screen.getByTestId('settings-nav-devices')).toBeInTheDocument();
expect(screen.getByTestId('settings-nav-data-sync')).toBeInTheDocument();
expect(screen.queryByTestId('settings-nav-team')).not.toBeInTheDocument();
expect(screen.queryByTestId('settings-nav-migration')).not.toBeInTheDocument();
});
it('renders the Assistant group items', () => {
renderSettingsHome();
// Only Personality + Face/Mascot stay layman-facing; the rest moved to
// Developer & Diagnostics.
expect(screen.getByTestId('settings-nav-persona')).toBeInTheDocument();
expect(screen.getByTestId('settings-nav-mascot')).toBeInTheDocument();
expect(screen.queryByTestId('settings-nav-voice')).not.toBeInTheDocument();
expect(screen.queryByTestId('settings-nav-permissions')).not.toBeInTheDocument();
expect(screen.queryByTestId('settings-nav-activity-level')).not.toBeInTheDocument();
expect(screen.queryByTestId('settings-nav-screen-intelligence')).not.toBeInTheDocument();
expect(screen.queryByTestId('settings-nav-companion')).not.toBeInTheDocument();
});
it('renders the Privacy group items', () => {
renderSettingsHome();
// Privacy stays; Security + Approvals moved to Developer & Diagnostics.
expect(screen.getByTestId('settings-nav-privacy')).toBeInTheDocument();
expect(screen.queryByTestId('settings-nav-security')).not.toBeInTheDocument();
expect(screen.queryByTestId('settings-nav-approval-history')).not.toBeInTheDocument();
});
it('renders the Notifications group item', () => {
renderSettingsHome();
expect(screen.getByTestId('settings-nav-notifications-hub')).toBeInTheDocument();
});
it('renders the About item always (even without developer mode)', () => {
renderSettingsHome({ developerMode: false });
expect(screen.getByTestId('settings-nav-about')).toBeInTheDocument();
});
it('old flat section headers are not rendered', () => {
// Section headers ("General", "Features & AI", "Billing & Rewards",
// "Support", "Danger Zone") were removed in Phase 4.
renderSettingsHome();
expect(screen.queryByText('Features & AI')).not.toBeInTheDocument();
expect(screen.queryByText('Support')).not.toBeInTheDocument();
expect(screen.queryByText('Danger Zone')).not.toBeInTheDocument();
});
});
describe('items no longer on the home screen', () => {
it('no longer renders Agents / Crypto section pages on the home screen', () => {
// These moved into the Developer & Diagnostics sub-tree (Agents & Autonomy).
renderSettingsHome();
expect(screen.queryByTestId('settings-nav-agents-settings')).not.toBeInTheDocument();
expect(screen.queryByTestId('settings-nav-crypto')).not.toBeInTheDocument();
});
it('no longer renders Alerts / stand-alone Notifications on the home screen', () => {
// Notifications now lives in its own Notifications group (notifications-hub).
renderSettingsHome();
expect(screen.queryByTestId('settings-nav-alerts')).not.toBeInTheDocument();
expect(screen.queryByTestId('settings-nav-notifications')).not.toBeInTheDocument();
@@ -116,78 +195,103 @@ describe('SettingsHome', () => {
expect(screen.queryByText('Log out')).not.toBeInTheDocument();
});
it('localizes Appearance and Mascot menu items', () => {
renderSettingsHome({ locale: 'zh-CN', withI18n: true });
expect(screen.getByText('外观')).toBeInTheDocument();
expect(screen.getByText('选择浅色、深色或跟随系统主题')).toBeInTheDocument();
expect(screen.getByText('吉祥物')).toBeInTheDocument();
expect(screen.getByText('选择应用内使用的吉祥物颜色')).toBeInTheDocument();
});
it('no longer renders Features / AI / Rewards / Restart Tour / About on the home screen', () => {
it('no longer renders Features / AI Configuration / Rewards / Restart Tour on the home screen', () => {
renderSettingsHome();
expect(screen.queryByText('Features')).not.toBeInTheDocument();
expect(screen.queryByText('AI Configuration')).not.toBeInTheDocument();
expect(screen.queryByText('Rewards')).not.toBeInTheDocument();
expect(screen.queryByText('Restart Tour')).not.toBeInTheDocument();
expect(screen.queryByText('About')).not.toBeInTheDocument();
});
});
describe('language selector', () => {
it('offers Bahasa Indonesia as a display language', () => {
renderSettingsHome();
expect(screen.getByRole('option', { name: /Bahasa Indonesia/ })).toHaveValue('id');
});
});
describe('existing navigation items', () => {
it('navigates to account settings when Account is clicked', async () => {
describe('navigation — layman groups', () => {
it('navigates to account settings when Profile is clicked', async () => {
const user = userEvent.setup();
renderSettingsHome();
await user.click(screen.getByText('Account').closest('button')!);
await user.click(screen.getByTestId('settings-nav-profile'));
expect(mockNavigateToSettings).toHaveBeenCalledWith('account');
});
it('navigates to the Agents section when Agents is clicked', async () => {
it('navigates to persona when Personality is clicked', async () => {
const user = userEvent.setup();
renderSettingsHome();
renderSettingsHome({ withI18n: true });
// Persona, Agent OS access, etc. now live under the Agents section page.
await user.click(screen.getByText('Agents').closest('button')!);
expect(mockNavigateToSettings).toHaveBeenCalledWith('agents-settings');
await user.click(screen.getByTestId('settings-nav-persona'));
expect(mockNavigateToSettings).toHaveBeenCalledWith('persona');
});
it('navigates to the Crypto section when Crypto is clicked', async () => {
it('navigates to mascot when Face / Mascot is clicked', async () => {
const user = userEvent.setup();
renderSettingsHome();
// Recovery phrase + wallet balances now live under the Crypto section page.
await user.click(screen.getByText('Crypto').closest('button')!);
expect(mockNavigateToSettings).toHaveBeenCalledWith('crypto');
await user.click(screen.getByTestId('settings-nav-mascot'));
expect(mockNavigateToSettings).toHaveBeenCalledWith('mascot');
});
it('opens billing URL when Billing & Usage is clicked', async () => {
const { openUrl } = await import('../../../utils/openUrl');
it('navigates to privacy when Privacy is clicked', async () => {
const user = userEvent.setup();
renderSettingsHome();
await user.click(screen.getByText('Billing & Usage').closest('button')!);
expect(openUrl).toHaveBeenCalledWith('https://billing.example.com');
await user.click(screen.getByTestId('settings-nav-privacy'));
expect(mockNavigateToSettings).toHaveBeenCalledWith('privacy');
});
it('navigates to developer-options when Advanced is clicked', async () => {
it('navigates to notifications-hub when Notifications is clicked', async () => {
const user = userEvent.setup();
renderSettingsHome();
await user.click(screen.getByText('Advanced').closest('button')!);
await user.click(screen.getByTestId('settings-nav-notifications-hub'));
expect(mockNavigateToSettings).toHaveBeenCalledWith('notifications-hub');
});
it('navigates to about when About is clicked', async () => {
const user = userEvent.setup();
renderSettingsHome();
await user.click(screen.getByTestId('settings-nav-about'));
expect(mockNavigateToSettings).toHaveBeenCalledWith('about');
});
it('does not render Billing & Usage in Settings (billing is in avatar menu)', () => {
// Per the IA redesign doc, billing/rewards live in the avatar menu — not in Settings.
renderSettingsHome();
expect(screen.queryByTestId('settings-nav-billing')).not.toBeInTheDocument();
expect(screen.queryByText('Billing & Usage')).not.toBeInTheDocument();
});
it('navigates to developer-options when "Developer & Diagnostics" is clicked (developerMode=true)', async () => {
const user = userEvent.setup();
renderSettingsHome({ developerMode: true });
await user.click(screen.getByTestId('settings-nav-developer-options'));
expect(mockNavigateToSettings).toHaveBeenCalledWith('developer-options');
});
});
describe('developer mode gate', () => {
it('hides the developer-options entry when developerMode is off', () => {
renderSettingsHome({ developerMode: false });
expect(screen.queryByTestId('settings-nav-developer-options')).not.toBeInTheDocument();
// The English resolved text should also be absent
expect(screen.queryByText('Developer & Diagnostics')).not.toBeInTheDocument();
});
it('shows the developer-options entry when developerMode is on', () => {
renderSettingsHome({ developerMode: true });
expect(screen.getByTestId('settings-nav-developer-options')).toBeInTheDocument();
// useT() resolves to English even without I18nProvider
expect(screen.getByText('Developer & Diagnostics')).toBeInTheDocument();
});
});
describe('local session gating', () => {
beforeEach(() => {
// Use a valid local-session token (three parts, last part = 'local')
@@ -198,17 +302,29 @@ describe('SettingsHome', () => {
mockSessionToken = null;
});
it('hides the Billing & Usage item in local mode', () => {
it('does not render Billing & Usage in Settings regardless of session type (billing is in avatar menu)', () => {
// Billing moved to avatar menu per IA redesign — never shown in Settings.
renderSettingsHome();
expect(screen.queryByText('Billing & Usage')).not.toBeInTheDocument();
});
it('shows "Billing & Usage" when not in local mode', () => {
it('does not render Billing & Usage in Settings even when not in local mode', () => {
// Billing moved to avatar menu per IA redesign — never shown in Settings.
mockSessionToken = null;
renderSettingsHome();
expect(screen.getByText('Billing & Usage')).toBeInTheDocument();
expect(screen.queryByText('Billing & Usage')).not.toBeInTheDocument();
});
});
describe('i18n — Chinese locale', () => {
it('localizes Appearance and Mascot menu items', () => {
renderSettingsHome({ locale: 'zh-CN', withI18n: true });
expect(screen.getByText('外观')).toBeInTheDocument();
expect(screen.getByText('选择浅色、深色或跟随系统主题')).toBeInTheDocument();
});
});
// Clear App Data flow moved to LogoutAndClearActions (rendered on Account
// page) — see LogoutAndClearActions.test.tsx.
});
@@ -53,7 +53,7 @@ const SettingsMenuItem = ({
type="button"
data-testid={testId}
onClick={onClick}
className={`w-full flex items-center justify-between py-3 px-4 bg-white dark:bg-neutral-900 text-stone-900 dark:text-neutral-100 ${borderClasses} hover:bg-stone-50 dark:hover:bg-neutral-800/60 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/60 transition-all duration-200 text-left ${roundedClasses} focus:outline-none focus:ring-0 focus:border-inherit`}>
className={`w-full flex items-center justify-between py-3 px-4 bg-stone-50 dark:bg-neutral-900/40 text-stone-900 dark:text-neutral-100 ${borderClasses} hover:bg-stone-100 dark:hover:bg-neutral-800/60 transition-all duration-200 text-left ${roundedClasses} focus:outline-none focus:ring-0 focus:border-inherit`}>
{content}
</button>
);
@@ -62,7 +62,7 @@ const SettingsMenuItem = ({
return (
<div
data-testid={testId}
className={`w-full flex items-center justify-between py-3 px-4 bg-white dark:bg-neutral-900 text-stone-900 dark:text-neutral-100 ${borderClasses} ${roundedClasses}`}>
className={`w-full flex items-center justify-between py-3 px-4 bg-stone-50 dark:bg-neutral-900/40 text-stone-900 dark:text-neutral-100 ${borderClasses} ${roundedClasses}`}>
{content}
</div>
);
@@ -24,6 +24,7 @@ export type SettingsRoute =
| 'voice'
| 'tools'
| 'memory-data'
| 'memory-sync'
| 'memory-debug'
| 'crypto'
| 'recovery-phrase'
@@ -49,7 +50,9 @@ export type SettingsRoute =
| 'mcp-server'
| 'dev-workflow'
| 'sandbox-settings'
| 'devices';
| 'permissions'
| 'devices'
| 'heartbeat';
export interface BreadcrumbItem {
label: string;
@@ -110,6 +113,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
if (path.includes('/settings/voice-debug')) return 'voice-debug';
if (path.includes('/settings/voice')) return 'voice';
if (path.includes('/settings/tools')) return 'tools';
if (path.includes('/settings/memory-sync')) return 'memory-sync';
if (path.includes('/settings/memory-data')) return 'memory-data';
if (path.includes('/settings/memory-debug')) return 'memory-debug';
if (path.includes('/settings/webhooks-debug')) return 'webhooks-debug';
@@ -141,10 +145,12 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
// shorter `agents` (the manage-agents registry panel) so it isn't swallowed.
if (path.includes('/settings/agents-settings')) return 'agents-settings';
if (path.includes('/settings/sandbox-settings')) return 'sandbox-settings';
if (path.includes('/settings/permissions')) return 'permissions';
if (path.includes('/settings/agent-access')) return 'agent-access';
if (path.includes('/settings/agents')) return 'agents';
if (path.includes('/settings/mcp-server')) return 'mcp-server';
if (path.includes('/settings/dev-workflow')) return 'dev-workflow';
if (path.includes('/settings/heartbeat')) return 'heartbeat';
return 'home';
};
@@ -286,6 +292,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'notification-routing':
case 'mcp-server':
case 'dev-workflow':
case 'heartbeat':
case 'notifications-hub': // Notifications hub section page lives under Advanced.
return [settingsCrumb, developerCrumb];
@@ -301,6 +308,14 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'devices':
return [settingsCrumb];
// Data Sync is a top-level leaf in the Account group (#3301).
case 'memory-sync':
return [settingsCrumb];
// Permissions panel lives at the top level of Settings (Assistant group).
case 'permissions':
return [settingsCrumb];
// Mascot appearance panel sits at the top level of Settings.
case 'mascot':
return [settingsCrumb];
@@ -10,8 +10,10 @@ import { invoke } from '@tauri-apps/api/core';
import { useEffect, useState } from 'react';
import { useAppUpdate } from '../../../hooks/useAppUpdate';
import { useDeveloperMode } from '../../../hooks/useDeveloperMode';
import { useT } from '../../../lib/i18n/I18nContext';
import { useAppSelector } from '../../../store/hooks';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { selectDeveloperMode, setDeveloperMode } from '../../../store/themeSlice';
import { APP_VERSION, LATEST_APP_DOWNLOAD_URL } from '../../../utils/config';
import { isTauriEnvironment } from '../../../utils/configPersistence';
import { openUrl } from '../../../utils/openUrl';
@@ -20,6 +22,7 @@ import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const AboutPanel = () => {
const { t } = useT();
const dispatch = useAppDispatch();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
// The auto-cadence is already running via the global <AppUpdatePrompt />;
// disable it here so opening the panel doesn't double-trigger probes.
@@ -27,6 +30,12 @@ const AboutPanel = () => {
const [lastCheckedAt, setLastCheckedAt] = useState<Date | null>(null);
const coreMode = useAppSelector(state => state.coreMode.mode);
const [rpcUrl, setRpcUrl] = useState<string | null>(null);
// Persisted developer mode preference (not the combined IS_DEV || developerMode).
// We read the raw preference here so the toggle reflects only the user's choice,
// not whether the build is a dev build.
const developerModePref = useAppSelector(selectDeveloperMode);
// Combined gate — true when IS_DEV or the pref is on. Used for the helper text.
const developerModeActive = useDeveloperMode();
// Local mode picks a dynamic port at app launch, so the authoritative
// value lives in the Tauri shell (`core_rpc_url` command) rather than the
@@ -148,6 +157,43 @@ const AboutPanel = () => {
</p>
</div>
{/* Developer Mode toggle — always visible so users can enable it
without needing it to be on first (chicken-and-egg avoidance). */}
<div
className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4"
data-testid="developer-mode-section">
<div className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('settings.developerMode.title')}
</div>
<div className="mt-1 text-xs text-stone-500 dark:text-neutral-400 leading-relaxed">
{developerModeActive && !developerModePref
? t('settings.developerMode.enabledByBuild')
: t('settings.developerMode.description')}
</div>
</div>
<button
type="button"
role="switch"
aria-checked={developerModePref}
aria-label={t('settings.developerMode.title')}
onClick={() => {
console.debug('[developer-mode] toggled to', !developerModePref);
dispatch(setDeveloperMode(!developerModePref));
}}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 ${
developerModePref ? 'bg-primary-600' : 'bg-stone-300 dark:bg-neutral-600'
}`}>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ${
developerModePref ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4">
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('settings.about.releases')}
@@ -2,13 +2,10 @@ import { useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
type AgentPaths,
type AutonomyLevel,
isTauri,
openhumanGetAgentPaths,
openhumanGetAgentSettings,
openhumanGetAutonomySettings,
openhumanUpdateAgentPaths,
openhumanUpdateAgentSettings,
openhumanUpdateAutonomySettings,
type TrustedAccess,
@@ -18,41 +15,19 @@ import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
// Installs are always *available* but never silent: every `install_tool` call
// is routed through the approval gate, so the user is asked to Approve/Deny each
// install in chat. There is therefore no per-user "disable installs" knob here —
// the consent is captured per-install by the gate, not by a static config flag.
// is routed through the approval gate, so the user is asked to Approve/Deny
// each install in chat. There is therefore no per-user "disable installs" knob
// here — the consent is captured per-install by the gate, not by a static
// config flag.
const ALLOW_TOOL_INSTALL = true;
interface PresetOption {
id: AutonomyLevel;
title: string;
description: string;
}
const AgentAccessPanel = () => {
const { t } = useT();
const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation();
// Tier presets — built inside the component so titles/descriptions resolve
// through `t()` (i18n). Order matters: it's the display order.
const presets: PresetOption[] = [
{
id: 'readonly',
title: t('settings.agentAccess.tier.readonly.title'),
description: t('settings.agentAccess.tier.readonly.desc'),
},
{
id: 'supervised',
title: t('settings.agentAccess.tier.supervised.title'),
description: t('settings.agentAccess.tier.supervised.desc'),
},
{
id: 'full',
title: t('settings.agentAccess.tier.full.title'),
description: t('settings.agentAccess.tier.full.desc'),
},
];
// Load `level` so we can carry it through when writing other fields, but
// the tier-selection UI lives in PermissionsPanel. Never render tier radios
// here — that would create two sources of truth.
const [level, setLevel] = useState<AutonomyLevel>('supervised');
const [workspaceOnly, setWorkspaceOnly] = useState(false);
const [requireTaskPlanApproval, setRequireTaskPlanApproval] = useState(true);
@@ -80,15 +55,6 @@ const AgentAccessPanel = () => {
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedNote, setSavedNote] = useState<string | null>(null);
// Live agent filesystem roots fetched from the core. `null` while the
// RPC is pending or when not running under Tauri — the JSX falls back to
// the documented defaults so the section never renders empty.
const [agentPaths, setAgentPaths] = useState<AgentPaths | null>(null);
const [actionDirEditing, setActionDirEditing] = useState(false);
const [actionDirInput, setActionDirInput] = useState('');
const [actionDirError, setActionDirError] = useState<string | null>(null);
const [actionDirSaved, setActionDirSaved] = useState<string | null>(null);
const [actionDirSaving, setActionDirSaving] = useState(false);
// Monotonic guard so out-of-order auto-save responses can't clobber UI state
// with a stale result (last write wins).
const persistSeqRef = useRef(0);
@@ -123,15 +89,6 @@ const AgentAccessPanel = () => {
} catch {
// Non-fatal: autonomy controls still render; timeout section
// stays at defaults and the user can try saving manually.
}
try {
const pathsResp = await openhumanGetAgentPaths();
if (cancelled) return;
setAgentPaths(pathsResp.result);
setActionDirInput(pathsResp.result.action_dir);
} catch {
// Non-fatal: the Directories section falls back to the documented
// defaults below. We don't gate the rest of the panel on this.
} finally {
if (!cancelled) setIsLoading(false);
}
@@ -144,16 +101,16 @@ const AgentAccessPanel = () => {
}, []);
// Auto-apply: every change persists immediately (no separate Save button).
// `allow_tool_install` is fixed; tier, workspace_only and granted folders
// vary. Pass explicit `next` values (setState is async).
// `allow_tool_install` is fixed; workspace_only, trusted_roots vary.
// `level` is carried through from state (its UI lives in PermissionsPanel).
// Pass explicit `next` values (setState is async).
const persist = async (next: {
level: AutonomyLevel;
workspaceOnly: boolean;
requireTaskPlanApproval: boolean;
trustedRoots: TrustedRoot[];
// Only sent when the allowlist itself is being changed. Omitting it leaves
// the server's `auto_approve` untouched (partial patch) — important so a
// tier/folder change here can't clobber a tool the user just added via the
// tier/folder change can't clobber a tool the user just added via the
// in-chat "Always allow" button.
autoApprove?: string[];
}) => {
@@ -164,7 +121,7 @@ const AgentAccessPanel = () => {
setIsSaving(true);
try {
await openhumanUpdateAutonomySettings({
level: next.level,
level,
workspace_only: next.workspaceOnly,
trusted_roots: next.trustedRoots,
allow_tool_install: ALLOW_TOOL_INSTALL,
@@ -186,52 +143,14 @@ const AgentAccessPanel = () => {
}
};
// True when the env var pins action_dir — the input must be disabled.
const actionDirEnvLocked = agentPaths?.action_dir_source === 'env';
const startEditActionDir = () => {
setActionDirInput(agentPaths?.action_dir ?? '');
setActionDirError(null);
setActionDirSaved(null);
setActionDirEditing(true);
};
const cancelEditActionDir = () => {
setActionDirEditing(false);
setActionDirError(null);
setActionDirInput('');
};
const saveActionDir = async () => {
if (!isTauri()) return;
setActionDirSaving(true);
setActionDirError(null);
setActionDirSaved(null);
try {
const resp = await openhumanUpdateAgentPaths({ action_dir: actionDirInput.trim() });
setAgentPaths(resp.result);
setActionDirEditing(false);
setActionDirSaved(t('settings.agentAccess.actionDir.saved'));
} catch (e) {
setActionDirError(e instanceof Error ? e.message : t('settings.agentAccess.saveError'));
} finally {
setActionDirSaving(false);
}
};
const selectTier = (next: AutonomyLevel) => {
setLevel(next);
void persist({ level: next, workspaceOnly, requireTaskPlanApproval, trustedRoots });
};
const toggleWorkspaceOnly = (next: boolean) => {
setWorkspaceOnly(next);
void persist({ level, workspaceOnly: next, requireTaskPlanApproval, trustedRoots });
void persist({ workspaceOnly: next, requireTaskPlanApproval, trustedRoots });
};
const toggleTaskPlanApproval = (next: boolean) => {
setRequireTaskPlanApproval(next);
void persist({ level, workspaceOnly, requireTaskPlanApproval: next, trustedRoots });
void persist({ workspaceOnly, requireTaskPlanApproval: next, trustedRoots });
};
const addRoot = () => {
@@ -245,25 +164,19 @@ const AgentAccessPanel = () => {
setTrustedRoots(nextRoots);
setNewRootPath('');
setNewRootAccess('read');
void persist({ level, workspaceOnly, requireTaskPlanApproval, trustedRoots: nextRoots });
void persist({ workspaceOnly, requireTaskPlanApproval, trustedRoots: nextRoots });
};
const removeRoot = (path: string) => {
const nextRoots = trustedRoots.filter(r => r.path !== path);
setTrustedRoots(nextRoots);
void persist({ level, workspaceOnly, requireTaskPlanApproval, trustedRoots: nextRoots });
void persist({ workspaceOnly, requireTaskPlanApproval, trustedRoots: nextRoots });
};
const removeAutoApprove = (tool: string) => {
const nextList = autoApprove.filter(name => name !== tool);
setAutoApprove(nextList);
void persist({
level,
workspaceOnly,
requireTaskPlanApproval,
trustedRoots,
autoApprove: nextList,
});
void persist({ workspaceOnly, requireTaskPlanApproval, trustedRoots, autoApprove: nextList });
};
// Persist the action timeout on blur / Enter. Validates the integer range
@@ -328,163 +241,8 @@ const AgentAccessPanel = () => {
</p>
) : (
<>
<section className="space-y-2">
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.agentAccess.accessMode')}
</h2>
<div className="grid gap-2">
{presets.map(p => (
<button
key={p.id}
type="button"
onClick={() => selectTier(p.id)}
className={`text-left rounded-lg border p-3 transition ${
level === p.id
? 'border-primary-500 bg-primary-50 dark:bg-primary-500/10'
: 'border-stone-200 dark:border-neutral-800 hover:border-primary-300 dark:hover:border-primary-500'
}`}>
<div className="flex items-center gap-2">
<span
className={`inline-block w-3 h-3 rounded-full border ${
level === p.id
? 'bg-primary-500 border-primary-500'
: 'border-stone-300 dark:border-neutral-700'
}`}
/>
<span className="font-medium text-stone-900 dark:text-neutral-100">
{p.title}
</span>
{p.id === 'supervised' && (
<span className="text-xs text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.defaultTag')}
</span>
)}
</div>
<p className="mt-1 text-xs text-stone-600 dark:text-neutral-400">
{p.description}
</p>
</button>
))}
{level === 'full' && (
<p className="rounded border border-coral/40 bg-coral/5 dark:bg-coral/10 p-2 text-xs text-coral-600 dark:text-coral-300">
{t('settings.agentAccess.fullWarning')}
</p>
)}
</div>
</section>
{/* Directory model — action sandbox vs internal state. */}
<section className="space-y-2">
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.agentAccess.directories')}
</h2>
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 divide-y divide-stone-200 dark:divide-neutral-800">
<div className="px-3 py-2">
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-sage-500" />
<span className="text-xs font-medium text-stone-900 dark:text-neutral-100">
{t('settings.agentAccess.actionSandbox')}
</span>
<span className="text-xs text-sage-600 dark:text-sage-400">
{t('settings.agentAccess.readWriteAccess')}
</span>
</div>
{actionDirEditing ? (
<div className="mt-1 space-y-1">
<div className="flex items-center gap-2">
<input
type="text"
className="flex-1 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs font-mono text-stone-900 dark:text-neutral-100"
value={actionDirInput}
onChange={e => setActionDirInput(e.target.value)}
placeholder={t('settings.agentAccess.actionDir.placeholder')}
disabled={actionDirSaving}
data-testid="agent-access-action-dir-input"
/>
<button
type="button"
className="rounded bg-ocean px-2 py-1 text-xs font-medium text-white disabled:opacity-50"
onClick={() => void saveActionDir()}
disabled={actionDirSaving}
data-testid="agent-access-action-dir-save">
{t('settings.agentAccess.actionDir.save')}
</button>
<button
type="button"
className="rounded border border-stone-300 dark:border-neutral-700 px-2 py-1 text-xs font-medium text-stone-700 dark:text-neutral-300 disabled:opacity-50"
onClick={cancelEditActionDir}
disabled={actionDirSaving}
data-testid="agent-access-action-dir-cancel">
{t('settings.agentAccess.actionDir.cancel')}
</button>
</div>
{actionDirError && (
<p
className="text-xs text-coral-600 dark:text-coral-400"
data-testid="agent-access-action-dir-error">
{actionDirError}
</p>
)}
</div>
) : (
<div className="mt-0.5 flex items-center gap-2">
<p
className="text-xs text-stone-600 dark:text-neutral-400 font-mono"
data-testid="agent-access-action-dir">
{agentPaths?.action_dir ?? '~/OpenHuman/projects'}
</p>
{!actionDirEnvLocked && (
<button
type="button"
className="text-xs font-medium text-ocean hover:underline"
onClick={startEditActionDir}
data-testid="agent-access-action-dir-edit">
{t('settings.agentAccess.actionDir.edit')}
</button>
)}
</div>
)}
{actionDirEnvLocked && (
<p
className="text-xs text-amber-600 dark:text-amber-400"
data-testid="agent-access-action-dir-env-locked">
{t('settings.agentAccess.actionDir.envLocked')}
</p>
)}
{actionDirSaved && !actionDirEditing && (
<p
className="text-xs text-sage-600 dark:text-sage-400"
data-testid="agent-access-action-dir-saved">
{actionDirSaved}
</p>
)}
<p className="text-xs text-stone-500 dark:text-neutral-500">
{t('settings.agentAccess.actionSandboxDesc')}
</p>
</div>
<div className="px-3 py-2">
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-coral-500" />
<span className="text-xs font-medium text-stone-900 dark:text-neutral-100">
{t('settings.agentAccess.internalState')}
</span>
<span className="text-xs text-coral-600 dark:text-coral-400">
{t('settings.agentAccess.agentBlocked')}
</span>
</div>
<p
className="mt-0.5 text-xs text-stone-600 dark:text-neutral-400 font-mono"
data-testid="agent-access-workspace-dir">
{agentPaths?.workspace_dir ?? '~/.openhuman/workspace'}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-500">
{t('settings.agentAccess.internalStateDesc')}
</p>
</div>
</div>
</section>
{/* Workspace confinement — orthogonal to the tier; applies in all modes. */}
{/* Workspace confinement — orthogonal to the tier; applies in all
modes. Tier selection moved to PermissionsPanel. */}
<section className="space-y-1">
<label className="flex items-start gap-2 cursor-pointer">
<input
@@ -0,0 +1,79 @@
import { useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import ConnectionPathTab from '../../intelligence/ConnectionPathTab';
import DiagramViewerTab from '../../intelligence/DiagramViewerTab';
import EntityAssociationsTab from '../../intelligence/EntityAssociationsTab';
import GraphCentralityTab from '../../intelligence/GraphCentralityTab';
import GraphCohesionTab from '../../intelligence/GraphCohesionTab';
import MemoryFreshnessTab from '../../intelligence/MemoryFreshnessTab';
import MemoryTimelineTab from '../../intelligence/MemoryTimelineTab';
import NamespaceOverviewTab from '../../intelligence/NamespaceOverviewTab';
import PillTabBar from '../../PillTabBar';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
/**
* Analysis views — the 8 parked memory-graph analysis surfaces.
*
* These were stripped from the layman Memory tab (#3397) but retained on disk
* (and still unit-tested). The IA redesign re-surfaces them here, behind the
* Developer & Diagnostics door → Knowledge & Memory → "Analysis views", so power
* users can still reach them while laymen never see them.
*/
type AnalysisView =
| 'diagram'
| 'centrality'
| 'cohesion'
| 'associations'
| 'freshness'
| 'timeline'
| 'paths'
| 'namespaces';
const AnalysisViewsPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [activeView, setActiveView] = useState<AnalysisView>('diagram');
const views: { id: AnalysisView; label: string }[] = [
{ id: 'diagram', label: t('memory.tab.diagram') },
{ id: 'centrality', label: t('memory.tab.centrality') },
{ id: 'cohesion', label: t('memory.tab.cohesion') },
{ id: 'associations', label: t('memory.tab.associations') },
{ id: 'freshness', label: t('memory.tab.freshness') },
{ id: 'timeline', label: t('memory.tab.timeline') },
{ id: 'paths', label: t('memory.tab.path') },
{ id: 'namespaces', label: t('memory.tab.namespaces') },
];
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.analysisViews.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-4">
<PillTabBar
items={views.map(view => ({ label: view.label, value: view.id }))}
selected={activeView}
onChange={setActiveView}
containerClassName="flex flex-wrap gap-2 pb-1"
/>
{activeView === 'diagram' && <DiagramViewerTab />}
{activeView === 'centrality' && <GraphCentralityTab />}
{activeView === 'cohesion' && <GraphCohesionTab />}
{activeView === 'associations' && <EntityAssociationsTab />}
{activeView === 'freshness' && <MemoryFreshnessTab />}
{activeView === 'timeline' && <MemoryTimelineTab />}
{activeView === 'paths' && <ConnectionPathTab />}
{activeView === 'namespaces' && <NamespaceOverviewTab />}
</div>
</div>
);
};
export default AnalysisViewsPanel;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
import { useCallback, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
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 { useSettingsNavigation } from '../hooks/useSettingsNavigation';
/**
* Data Sync — top-level Settings → Account page (#3301).
*
* The single, focused home for "what syncs, how much, how fresh, and is it
* running right now". Shows the source registry (per-source rows, opt-in
* toggles, live "syncing now" indicator, per-source settings, and the global
* sync-schedule control) plus the Sync History audit table.
*
* The "Memory Tree" status tiles / per-integration health and the
* force-directed memory graph deliberately stay on the developer Memory page
* (Dev & Diagnostics → Memory), not here.
*/
const MemorySyncPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const [toasts, setToasts] = useState<ToastNotification[]>([]);
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
const newToast: ToastNotification = { ...toast, id: `toast-${Date.now()}-${Math.random()}` };
setToasts(prev => [...prev, newToast]);
}, []);
const removeToast = (id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
};
return (
<div className="z-10 relative">
<SettingsHeader
title={t('settings.dataSync.title')}
showBackButton={true}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-4">
<p className="text-sm text-stone-600 dark:text-neutral-300">
{t('settings.dataSync.description')}
</p>
<MemorySourcesRegistry onToast={addToast} />
<div className="rounded-lg border border-stone-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<h3 className="mb-2 text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('sync.auditTitle', 'Sync History')}
</h3>
<SyncAuditPanel />
</div>
</div>
<ToastContainer notifications={toasts} onRemove={removeToast} />
</div>
);
};
export default MemorySyncPanel;
@@ -0,0 +1,373 @@
import { useEffect, useRef, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import {
type AgentPaths,
type AutonomyLevel,
isTauri,
openhumanGetAgentPaths,
openhumanGetAutonomySettings,
openhumanUpdateAgentPaths,
openhumanUpdateAutonomySettings,
} from '../../../utils/tauriCommands';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
// Installs are always *available* but never silent: every `install_tool` call
// is routed through the approval gate, so the user is asked to Approve/Deny
// each install in chat. There is no per-user "disable installs" knob here —
// the consent is captured per-install by the gate, not by a static config flag.
const ALLOW_TOOL_INSTALL = true;
interface PresetOption {
id: AutonomyLevel;
title: string;
description: string;
}
const PermissionsPanel = () => {
const { t } = useT();
const { navigateBack, breadcrumbs } = useSettingsNavigation();
// Tier presets — built inside the component so titles/descriptions resolve
// through `t()` (i18n). Order matters: it's the display order.
const presets: PresetOption[] = [
{
id: 'readonly',
title: t('settings.permissions.preset.readonly.title'),
description: t('settings.permissions.preset.readonly.desc'),
},
{
id: 'supervised',
title: t('settings.permissions.preset.supervised.title'),
description: t('settings.permissions.preset.supervised.desc'),
},
{
id: 'full',
title: t('settings.permissions.preset.full.title'),
description: t('settings.permissions.preset.full.desc'),
},
];
const [level, setLevel] = useState<AutonomyLevel>('supervised');
// We need to carry workspace_only and trusted_roots when saving tier changes
// so we don't overwrite them with defaults. Load them but don't expose UI for
// them (they live in the advanced panel).
const [workspaceOnly, setWorkspaceOnly] = useState(false);
const [requireTaskPlanApproval, setRequireTaskPlanApproval] = useState(true);
const [trustedRoots, setTrustedRoots] = useState<
Array<{ path: string; access: 'read' | 'readwrite' }>
>([]);
const [agentPaths, setAgentPaths] = useState<AgentPaths | null>(null);
const [actionDirEditing, setActionDirEditing] = useState(false);
const [actionDirInput, setActionDirInput] = useState('');
const [actionDirError, setActionDirError] = useState<string | null>(null);
const [actionDirSaved, setActionDirSaved] = useState<string | null>(null);
const [actionDirSaving, setActionDirSaving] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedNote, setSavedNote] = useState<string | null>(null);
// Monotonic guards so out-of-order async responses don't clobber UI state.
const persistSeqRef = useRef(0);
const dirSeqRef = useRef(0);
useEffect(() => {
let cancelled = false;
const load = async () => {
if (!isTauri()) {
setIsLoading(false);
return;
}
try {
const autonomyResp = await openhumanGetAutonomySettings();
if (cancelled) return;
setLevel(autonomyResp.result.level);
setWorkspaceOnly(autonomyResp.result.workspace_only);
setRequireTaskPlanApproval(autonomyResp.result.require_task_plan_approval ?? true);
setTrustedRoots(autonomyResp.result.trusted_roots ?? []);
} catch (e) {
if (!cancelled)
setError(e instanceof Error ? e.message : t('settings.agentAccess.loadError'));
}
try {
const pathsResp = await openhumanGetAgentPaths();
if (cancelled) return;
setAgentPaths(pathsResp.result);
setActionDirInput(pathsResp.result.action_dir);
} catch {
// Non-fatal: folder section falls back to documented defaults.
} finally {
if (!cancelled) setIsLoading(false);
}
};
void load();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Persist tier change. Carries the other autonomy fields through unchanged
// so we don't accidentally clobber what the advanced panel may have set.
const persistTier = async (nextLevel: AutonomyLevel) => {
const seq = ++persistSeqRef.current;
if (!isTauri()) return;
setError(null);
setSavedNote(null);
setIsSaving(true);
try {
await openhumanUpdateAutonomySettings({
level: nextLevel,
workspace_only: workspaceOnly,
trusted_roots: trustedRoots,
allow_tool_install: ALLOW_TOOL_INSTALL,
require_task_plan_approval: requireTaskPlanApproval,
});
if (persistSeqRef.current === seq) {
setSavedNote(t('settings.agentAccess.saved'));
}
} catch (e) {
if (persistSeqRef.current === seq) {
setError(e instanceof Error ? e.message : t('settings.agentAccess.saveError'));
}
} finally {
if (persistSeqRef.current === seq) {
setIsSaving(false);
}
}
};
const selectTier = (next: AutonomyLevel) => {
setLevel(next);
void persistTier(next);
};
// True when the env var pins action_dir — the edit button must be hidden.
const actionDirEnvLocked = agentPaths?.action_dir_source === 'env';
const startEditActionDir = () => {
setActionDirInput(agentPaths?.action_dir ?? '');
setActionDirError(null);
setActionDirSaved(null);
setActionDirEditing(true);
};
const cancelEditActionDir = () => {
setActionDirEditing(false);
setActionDirError(null);
setActionDirInput('');
};
const saveActionDir = async () => {
if (!isTauri()) return;
const seq = ++dirSeqRef.current;
setActionDirSaving(true);
setActionDirError(null);
setActionDirSaved(null);
try {
const resp = await openhumanUpdateAgentPaths({ action_dir: actionDirInput.trim() });
if (dirSeqRef.current === seq) {
setAgentPaths(resp.result);
setActionDirEditing(false);
setActionDirSaved(t('settings.agentAccess.actionDir.saved'));
}
} catch (e) {
if (dirSeqRef.current === seq) {
setActionDirError(e instanceof Error ? e.message : t('settings.agentAccess.saveError'));
}
} finally {
if (dirSeqRef.current === seq) {
setActionDirSaving(false);
}
}
};
return (
<div>
<SettingsHeader
title={t('settings.permissions.title')}
showBackButton
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>
<div className="p-4 space-y-6">
{!isTauri() && (
<p className="text-sm text-coral-600 dark:text-coral-300">
{t('settings.agentAccess.desktopOnly')}
</p>
)}
{isLoading ? (
<p className="text-sm text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.loading')}
</p>
) : (
<>
{/* Access mode presets — layman-friendly labels */}
<section className="space-y-2">
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.permissions.accessMode')}
</h2>
<p className="text-xs text-stone-600 dark:text-neutral-400">
{t('settings.permissions.accessModeDesc')}
</p>
<div className="grid gap-2">
{presets.map(p => (
<button
key={p.id}
type="button"
onClick={() => selectTier(p.id)}
data-testid={`permissions-preset-${p.id}`}
className={`text-left rounded-lg border p-3 transition ${
level === p.id
? 'border-primary-500 bg-primary-50 dark:bg-primary-500/10'
: 'border-stone-200 dark:border-neutral-800 hover:border-primary-300 dark:hover:border-primary-500'
}`}>
<div className="flex items-center gap-2">
<span
className={`inline-block w-3 h-3 rounded-full border ${
level === p.id
? 'bg-primary-500 border-primary-500'
: 'border-stone-300 dark:border-neutral-700'
}`}
/>
<span className="font-medium text-stone-900 dark:text-neutral-100">
{p.title}
</span>
{p.id === 'supervised' && (
<span className="text-xs text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.defaultTag')}
</span>
)}
</div>
<p className="mt-1 text-xs text-stone-600 dark:text-neutral-400">
{p.description}
</p>
</button>
))}
{level === 'full' && (
<p className="rounded border border-coral/40 bg-coral/5 dark:bg-coral/10 p-2 text-xs text-coral-600 dark:text-coral-300">
{t('settings.agentAccess.fullWarning')}
</p>
)}
</div>
</section>
{/* Folders the assistant can use */}
<section className="space-y-2">
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('settings.permissions.folders')}
</h2>
<p className="text-xs text-stone-600 dark:text-neutral-400">
{t('settings.permissions.foldersDesc')}
</p>
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-2">
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-sage-500" />
<span className="text-xs font-medium text-stone-900 dark:text-neutral-100">
{t('settings.agentAccess.actionSandbox')}
</span>
<span className="text-xs text-sage-600 dark:text-sage-400">
{t('settings.agentAccess.readWriteAccess')}
</span>
</div>
{actionDirEditing ? (
<div className="mt-1 space-y-1">
<div className="flex items-center gap-2">
<input
type="text"
className="flex-1 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-xs font-mono text-stone-900 dark:text-neutral-100"
value={actionDirInput}
onChange={e => setActionDirInput(e.target.value)}
placeholder={t('settings.agentAccess.actionDir.placeholder')}
disabled={actionDirSaving}
data-testid="permissions-action-dir-input"
/>
<button
type="button"
className="rounded bg-ocean px-2 py-1 text-xs font-medium text-white disabled:opacity-50"
onClick={() => void saveActionDir()}
disabled={actionDirSaving}
data-testid="permissions-action-dir-save">
{t('settings.agentAccess.actionDir.save')}
</button>
<button
type="button"
className="rounded border border-stone-300 dark:border-neutral-700 px-2 py-1 text-xs font-medium text-stone-700 dark:text-neutral-300 disabled:opacity-50"
onClick={cancelEditActionDir}
disabled={actionDirSaving}
data-testid="permissions-action-dir-cancel">
{t('settings.agentAccess.actionDir.cancel')}
</button>
</div>
{actionDirError && (
<p
className="text-xs text-coral-600 dark:text-coral-400"
data-testid="permissions-action-dir-error">
{actionDirError}
</p>
)}
</div>
) : (
<div className="mt-0.5 flex items-center gap-2">
<p
className="text-xs text-stone-600 dark:text-neutral-400 font-mono"
data-testid="permissions-action-dir">
{agentPaths?.action_dir ?? '~/OpenHuman/projects'}
</p>
{!actionDirEnvLocked && (
<button
type="button"
className="text-xs font-medium text-ocean hover:underline"
onClick={startEditActionDir}
data-testid="permissions-action-dir-edit">
{t('settings.agentAccess.actionDir.edit')}
</button>
)}
</div>
)}
{actionDirEnvLocked && (
<p
className="text-xs text-amber-600 dark:text-amber-400"
data-testid="permissions-action-dir-env-locked">
{t('settings.agentAccess.actionDir.envLocked')}
</p>
)}
{actionDirSaved && !actionDirEditing && (
<p className="text-xs text-sage-600 dark:text-sage-400">{actionDirSaved}</p>
)}
<p className="text-xs text-stone-500 dark:text-neutral-500 mt-0.5">
{t('settings.agentAccess.actionSandboxDesc')}
</p>
</div>
</section>
{/* Auto-save status */}
<div className="min-h-[1.25rem] text-sm" aria-live="polite">
{error ? (
<span className="text-coral-600 dark:text-coral-300">{error}</span>
) : isSaving ? (
<span className="text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.saving')}
</span>
) : savedNote ? (
<span className="text-sage-700 dark:text-sage-300"> {savedNote}</span>
) : (
<span className="text-stone-600 dark:text-neutral-400">
{t('settings.agentAccess.changesApply')}
</span>
)}
</div>
</>
)}
</div>
</div>
);
};
export default PermissionsPanel;
@@ -151,3 +151,54 @@ describe('AboutPanel', () => {
});
});
});
describe('AboutPanel — Developer Mode toggle', () => {
beforeEach(() => {
statusListeners.length = 0;
mockCheckAppUpdate.mockReset();
hoisted.mockIsTauri.mockReturnValue(false); // Simplify — no Tauri IPC for these tests
});
// useT() falls back to English even without I18nProvider (resolveEn() lookup).
// The toggle's aria-label is set to t('settings.developerMode.title') which
// resolves to the English string "Developer mode".
const TOGGLE_ARIA_LABEL = /Developer mode/i;
it('renders the Developer mode switch section', () => {
renderWithProviders(<AboutPanel />);
expect(screen.getByRole('switch', { name: TOGGLE_ARIA_LABEL })).toBeInTheDocument();
expect(screen.getByTestId('developer-mode-section')).toBeInTheDocument();
});
it('switch defaults to off (aria-checked=false)', () => {
renderWithProviders(<AboutPanel />);
const toggle = screen.getByRole('switch', { name: TOGGLE_ARIA_LABEL });
expect(toggle).toHaveAttribute('aria-checked', 'false');
});
it('turns on developer mode when toggle is clicked', () => {
const { store } = renderWithProviders(<AboutPanel />);
const toggle = screen.getByRole('switch', { name: TOGGLE_ARIA_LABEL });
fireEvent.click(toggle);
expect(store.getState().theme.developerMode).toBe(true);
expect(toggle).toHaveAttribute('aria-checked', 'true');
});
it('toggles developer mode off when clicked again', () => {
const { store } = renderWithProviders(<AboutPanel />, {
preloadedState: {
theme: {
mode: 'system',
tabBarLabels: 'hover',
fontSize: 'medium',
agentMessageViewMode: 'bubbles',
developerMode: true,
},
},
});
const toggle = screen.getByRole('switch', { name: TOGGLE_ARIA_LABEL });
expect(toggle).toHaveAttribute('aria-checked', 'true');
fireEvent.click(toggle);
expect(store.getState().theme.developerMode).toBe(false);
});
});
@@ -3,19 +3,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
import {
type AgentPaths,
type AgentSettings,
type AutonomySettings,
isTauri,
openhumanGetAgentPaths,
openhumanGetAgentSettings,
openhumanGetAutonomySettings,
openhumanUpdateAgentPaths,
openhumanUpdateAgentSettings,
openhumanUpdateAutonomySettings,
} from '../../../../utils/tauriCommands';
import AgentAccessPanel from '../AgentAccessPanel';
// ──────────────────────────────────────────────────────────────────────────────
// Note: Tier-selection and action-dir editing tests live in
// PermissionsPanel.test.tsx (those controls moved to the layman panel).
// This file covers the ADVANCED surface: workspace confinement, task-plan
// approval, action timeout, granted folders, always-allowed tools, and the
// approval-history link.
// ──────────────────────────────────────────────────────────────────────────────
const autonomy = (overrides: Partial<AutonomySettings> = {}): AutonomySettings => ({
level: 'supervised',
workspace_only: false,
@@ -37,14 +42,6 @@ const agentSettings = (overrides: Partial<AgentSettings> = {}): AgentSettings =>
...overrides,
});
const agentPaths = (overrides: Partial<AgentPaths> = {}): AgentPaths => ({
action_dir: '/home/test/OpenHuman/projects',
workspace_dir: '/home/test/.openhuman/users/u1/workspace',
projects_dir: '/home/test/OpenHuman/projects',
action_dir_source: 'default',
...overrides,
});
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({
navigateBack: vi.fn(),
@@ -64,8 +61,8 @@ vi.mock('../../../../utils/tauriCommands', async () => {
openhumanUpdateAutonomySettings: vi.fn(),
openhumanGetAgentSettings: vi.fn(),
openhumanUpdateAgentSettings: vi.fn(),
openhumanGetAgentPaths: vi.fn(),
openhumanUpdateAgentPaths: vi.fn(),
// The advanced panel no longer calls the agent-paths RPCs (action-dir
// moved to PermissionsPanel) — no mock needed, but keep the import clean.
};
});
@@ -73,10 +70,8 @@ const mockGet = vi.mocked(openhumanGetAutonomySettings);
const mockUpdate = vi.mocked(openhumanUpdateAutonomySettings);
const mockGetAgent = vi.mocked(openhumanGetAgentSettings);
const mockUpdateAgent = vi.mocked(openhumanUpdateAgentSettings);
const mockGetAgentPaths = vi.mocked(openhumanGetAgentPaths);
const mockUpdateAgentPaths = vi.mocked(openhumanUpdateAgentPaths);
describe('AgentAccessPanel', () => {
describe('AgentAccessPanel (advanced)', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isTauri).mockReturnValue(true);
@@ -84,31 +79,24 @@ describe('AgentAccessPanel', () => {
mockUpdate.mockResolvedValue({ result: {} as never, logs: [] });
mockGetAgent.mockResolvedValue({ result: agentSettings(), logs: [] });
mockUpdateAgent.mockResolvedValue({ result: {} as never, logs: [] });
mockGetAgentPaths.mockResolvedValue({ result: agentPaths(), logs: [] });
mockUpdateAgentPaths.mockResolvedValue({ result: agentPaths(), logs: [] });
});
it('loads settings on mount and renders the three access tiers', async () => {
it('loads settings on mount and renders the advanced controls', async () => {
renderWithProviders(<AgentAccessPanel />);
await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(1));
expect(await screen.findByText('Read-only')).toBeInTheDocument();
expect(screen.getByText('Ask before edit')).toBeInTheDocument();
expect(screen.getByText('Full access')).toBeInTheDocument();
});
it('selecting the Full tier persists the new level (and renders the warning)', async () => {
renderWithProviders(<AgentAccessPanel />);
fireEvent.click(await screen.findByText('Full access'));
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({ level: 'full', allow_tool_install: true })
)
);
// The tier radio buttons no longer live in this panel.
expect(screen.queryByText('Read-only')).not.toBeInTheDocument();
expect(screen.queryByText('Ask before edit')).not.toBeInTheDocument();
expect(screen.queryByText('Full access')).not.toBeInTheDocument();
// The advanced controls are present.
expect(await screen.findByText('Confine to workspace')).toBeInTheDocument();
expect(screen.getByText('Granted folders')).toBeInTheDocument();
expect(screen.getByText('Always-allowed tools')).toBeInTheDocument();
});
it('toggling "confine to workspace" persists workspace_only', async () => {
renderWithProviders(<AgentAccessPanel />);
await screen.findByText('Read-only');
await screen.findByText('Confine to workspace');
fireEvent.click(screen.getByRole('checkbox', { name: /confine to workspace/i }));
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({ workspace_only: true }))
@@ -117,7 +105,7 @@ describe('AgentAccessPanel', () => {
it('toggling task plan approval persists require_task_plan_approval', async () => {
renderWithProviders(<AgentAccessPanel />);
await screen.findByText('Read-only');
await screen.findByText('Confine to workspace');
fireEvent.click(screen.getByRole('checkbox', { name: /require task plan approval/i }));
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(
@@ -146,7 +134,7 @@ describe('AgentAccessPanel', () => {
);
});
it('renders the loaded tier from settings and pre-existing granted folders', async () => {
it('renders the loaded tier and pre-existing granted folders (loaded but not shown)', async () => {
mockGet.mockResolvedValue({
result: autonomy({
level: 'readonly',
@@ -156,10 +144,14 @@ describe('AgentAccessPanel', () => {
logs: [],
});
renderWithProviders(<AgentAccessPanel />);
// Folder path is visible.
expect(await screen.findByText('/home/u/notes')).toBeInTheDocument();
// workspace_only checkbox is checked.
expect(
(screen.getByRole('checkbox', { name: /confine to workspace/i }) as HTMLInputElement).checked
).toBe(true);
// But the tier radio UI is NOT here (lives in PermissionsPanel).
expect(screen.queryByText('Read-only')).not.toBeInTheDocument();
});
it('shows the empty "always-allow" state when no tools are allow-listed', async () => {
@@ -177,8 +169,7 @@ describe('AgentAccessPanel', () => {
expect(screen.getByText('curl')).toBeInTheDocument();
// trusted_roots is empty, so the only Remove buttons belong to the
// allowlist. Removing the first entry persists the trimmed list via
// update_autonomy_settings (auto_approve only — other fields untouched).
// allowlist. Removing the first entry persists the trimmed list.
fireEvent.click(screen.getAllByText('Remove')[0]);
await waitFor(() =>
expect(mockUpdate).toHaveBeenLastCalledWith(
@@ -196,7 +187,9 @@ describe('AgentAccessPanel', () => {
it('shows the desktop-only notice and skips loading off-Tauri', async () => {
vi.mocked(isTauri).mockReturnValue(false);
renderWithProviders(<AgentAccessPanel />);
expect(await screen.findByText('Access mode')).toBeInTheDocument();
expect(
await screen.findByText('Access settings are only available in the desktop app.')
).toBeInTheDocument();
expect(mockGet).not.toHaveBeenCalled();
expect(mockGetAgent).not.toHaveBeenCalled();
});
@@ -243,120 +236,4 @@ describe('AgentAccessPanel', () => {
expect(input.disabled).toBe(true);
expect(screen.getByText(/OPENHUMAN_TOOL_TIMEOUT_SECS/)).toBeInTheDocument();
});
// ── Directories section (#3237) ───────────────────────────────────────────
it('renders the live action_dir and workspace_dir returned by the core', async () => {
mockGetAgentPaths.mockResolvedValue({
result: agentPaths({
action_dir: '/Users/sample/OpenHuman/projects',
workspace_dir: '/Users/sample/.openhuman/users/u1/workspace',
}),
logs: [],
});
renderWithProviders(<AgentAccessPanel />);
await waitFor(() => expect(mockGetAgentPaths).toHaveBeenCalledTimes(1));
expect(await screen.findByTestId('agent-access-action-dir')).toHaveTextContent(
'/Users/sample/OpenHuman/projects'
);
expect(screen.getByTestId('agent-access-workspace-dir')).toHaveTextContent(
'/Users/sample/.openhuman/users/u1/workspace'
);
});
it('reflects an OPENHUMAN_ACTION_DIR override in the action sandbox row', async () => {
// When the operator sets OPENHUMAN_ACTION_DIR, the core's get_agent_paths
// returns the override value as `action_dir`. The panel must render that
// verbatim instead of the hard-coded `~/OpenHuman/projects` default —
// otherwise Settings actively misleads about where the agent writes.
mockGetAgentPaths.mockResolvedValue({
result: agentPaths({
action_dir: '/tmp/custom-actions',
projects_dir: '/Users/sample/OpenHuman/projects',
}),
logs: [],
});
renderWithProviders(<AgentAccessPanel />);
expect(await screen.findByTestId('agent-access-action-dir')).toHaveTextContent(
'/tmp/custom-actions'
);
});
it('falls back to documented defaults when the agent paths RPC fails', async () => {
// Non-fatal failure path: the rest of the panel must still render and
// the Directories rows must show the documented default strings so the
// section never appears empty.
mockGetAgentPaths.mockRejectedValue(new Error('rpc unavailable'));
renderWithProviders(<AgentAccessPanel />);
expect(await screen.findByTestId('agent-access-action-dir')).toHaveTextContent(
'~/OpenHuman/projects'
);
expect(screen.getByTestId('agent-access-workspace-dir')).toHaveTextContent(
'~/.openhuman/workspace'
);
});
it('shows an Edit affordance for action_dir when the source is not env', async () => {
mockGetAgentPaths.mockResolvedValue({
result: agentPaths({ action_dir: '/Users/sample/projects', action_dir_source: 'default' }),
logs: [],
});
renderWithProviders(<AgentAccessPanel />);
expect(await screen.findByTestId('agent-access-action-dir-edit')).toBeInTheDocument();
expect(screen.queryByTestId('agent-access-action-dir-env-locked')).not.toBeInTheDocument();
});
it('saving a new action_dir calls openhumanUpdateAgentPaths and updates the display', async () => {
mockGetAgentPaths.mockResolvedValue({
result: agentPaths({ action_dir: '/Users/sample/old', action_dir_source: 'default' }),
logs: [],
});
mockUpdateAgentPaths.mockResolvedValue({
result: agentPaths({ action_dir: '/Users/sample/new', action_dir_source: 'override' }),
logs: [],
});
renderWithProviders(<AgentAccessPanel />);
fireEvent.click(await screen.findByTestId('agent-access-action-dir-edit'));
const input = await screen.findByTestId('agent-access-action-dir-input');
fireEvent.change(input, { target: { value: '/Users/sample/new' } });
fireEvent.click(screen.getByTestId('agent-access-action-dir-save'));
await waitFor(() =>
expect(mockUpdateAgentPaths).toHaveBeenCalledWith({ action_dir: '/Users/sample/new' })
);
expect(await screen.findByTestId('agent-access-action-dir')).toHaveTextContent(
'/Users/sample/new'
);
});
it('renders a backend validation error inline without leaving edit mode', async () => {
mockGetAgentPaths.mockResolvedValue({
result: agentPaths({ action_dir: '/Users/sample/old', action_dir_source: 'default' }),
logs: [],
});
mockUpdateAgentPaths.mockRejectedValue(new Error('action_dir must be an absolute path'));
renderWithProviders(<AgentAccessPanel />);
fireEvent.click(await screen.findByTestId('agent-access-action-dir-edit'));
const input = await screen.findByTestId('agent-access-action-dir-input');
fireEvent.change(input, { target: { value: 'relative/path' } });
fireEvent.click(screen.getByTestId('agent-access-action-dir-save'));
expect(await screen.findByTestId('agent-access-action-dir-error')).toHaveTextContent(
'action_dir must be an absolute path'
);
expect(screen.getByTestId('agent-access-action-dir-input')).toBeInTheDocument();
});
it('disables editing and shows the env-locked notice when source is env', async () => {
mockGetAgentPaths.mockResolvedValue({
result: agentPaths({ action_dir: '/tmp/env-pinned', action_dir_source: 'env' }),
logs: [],
});
renderWithProviders(<AgentAccessPanel />);
await screen.findByTestId('agent-access-action-dir');
expect(screen.queryByTestId('agent-access-action-dir-edit')).not.toBeInTheDocument();
expect(screen.getByTestId('agent-access-action-dir-env-locked')).toBeInTheDocument();
});
});
@@ -124,7 +124,9 @@ describe('DeveloperOptionsPanel — CoreModeBadge', () => {
);
expect(screen.getByText('AI 配置')).toBeInTheDocument();
expect(screen.getByText('屏幕感知')).toBeInTheDocument();
// Two screen-awareness rows now exist (the moved settings row + the debug
// panel), which collapse to the same zh-CN label — assert at least one.
expect(screen.getAllByText('屏幕感知').length).toBeGreaterThan(0);
// The messaging tile was removed; composio replaced it as a single destination.
expect(screen.getByText('Composio')).toBeInTheDocument();
});
@@ -0,0 +1,239 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
import {
type AgentPaths,
type AutonomySettings,
isTauri,
openhumanGetAgentPaths,
openhumanGetAutonomySettings,
openhumanUpdateAgentPaths,
openhumanUpdateAutonomySettings,
} from '../../../../utils/tauriCommands';
import PermissionsPanel from '../PermissionsPanel';
const autonomy = (overrides: Partial<AutonomySettings> = {}): AutonomySettings => ({
level: 'supervised',
workspace_only: false,
allowed_commands: [],
forbidden_paths: [],
trusted_roots: [],
allow_tool_install: true,
max_actions_per_hour: 0,
auto_approve: [],
...overrides,
});
const agentPaths = (overrides: Partial<AgentPaths> = {}): AgentPaths => ({
action_dir: '/home/test/OpenHuman/projects',
workspace_dir: '/home/test/.openhuman/users/u1/workspace',
projects_dir: '/home/test/OpenHuman/projects',
action_dir_source: 'default',
...overrides,
});
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({
navigateBack: vi.fn(),
navigateToSettings: vi.fn(),
breadcrumbs: [],
}),
}));
vi.mock('../../../../utils/tauriCommands', async () => {
const actual = await vi.importActual<typeof import('../../../../utils/tauriCommands')>(
'../../../../utils/tauriCommands'
);
return {
...actual,
isTauri: vi.fn(() => true),
openhumanGetAutonomySettings: vi.fn(),
openhumanUpdateAutonomySettings: vi.fn(),
openhumanGetAgentPaths: vi.fn(),
openhumanUpdateAgentPaths: vi.fn(),
};
});
const mockGet = vi.mocked(openhumanGetAutonomySettings);
const mockUpdate = vi.mocked(openhumanUpdateAutonomySettings);
const mockGetPaths = vi.mocked(openhumanGetAgentPaths);
const mockUpdatePaths = vi.mocked(openhumanUpdateAgentPaths);
describe('PermissionsPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isTauri).mockReturnValue(true);
mockGet.mockResolvedValue({ result: autonomy(), logs: [] });
mockUpdate.mockResolvedValue({ result: {} as never, logs: [] });
mockGetPaths.mockResolvedValue({ result: agentPaths(), logs: [] });
mockUpdatePaths.mockResolvedValue({ result: agentPaths(), logs: [] });
});
it('loads settings on mount and renders all three presets', async () => {
renderWithProviders(<PermissionsPanel />);
await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(1));
expect(await screen.findByText("Look, don't touch")).toBeInTheDocument();
expect(screen.getByText('Ask me first')).toBeInTheDocument();
expect(screen.getByText('Full control')).toBeInTheDocument();
});
it('highlights the currently-selected preset on load (supervised by default)', async () => {
renderWithProviders(<PermissionsPanel />);
const supervisedBtn = await screen.findByTestId('permissions-preset-supervised');
// Active preset has bg-primary-50 (selected visual indicator).
expect(supervisedBtn.className).toContain('bg-primary-50');
const readonlyBtn = screen.getByTestId('permissions-preset-readonly');
expect(readonlyBtn.className).not.toContain('bg-primary-50');
});
it('selecting the "Look, don\'t touch" preset persists readonly level', async () => {
renderWithProviders(<PermissionsPanel />);
fireEvent.click(await screen.findByText("Look, don't touch"));
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({ level: 'readonly', allow_tool_install: true })
)
);
});
it('selecting the "Full control" preset persists full level', async () => {
renderWithProviders(<PermissionsPanel />);
fireEvent.click(await screen.findByText('Full control'));
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({ level: 'full', allow_tool_install: true })
)
);
});
it('shows the full-access warning when full control is active', async () => {
mockGet.mockResolvedValue({ result: autonomy({ level: 'full' }), logs: [] });
renderWithProviders(<PermissionsPanel />);
// The fullWarning message from agentAccess i18n key appears.
expect(await screen.findByText(/Full access runs commands/i)).toBeInTheDocument();
});
it('loads and displays the action_dir from the core', async () => {
mockGetPaths.mockResolvedValue({
result: agentPaths({ action_dir: '/Users/sample/OpenHuman/projects' }),
logs: [],
});
renderWithProviders(<PermissionsPanel />);
await waitFor(() => expect(mockGetPaths).toHaveBeenCalledTimes(1));
expect(await screen.findByTestId('permissions-action-dir')).toHaveTextContent(
'/Users/sample/OpenHuman/projects'
);
});
it('falls back to the documented default when the agent paths RPC fails', async () => {
mockGetPaths.mockRejectedValue(new Error('rpc unavailable'));
renderWithProviders(<PermissionsPanel />);
expect(await screen.findByTestId('permissions-action-dir')).toHaveTextContent(
'~/OpenHuman/projects'
);
});
it('shows an Edit affordance when action_dir_source is not env', async () => {
mockGetPaths.mockResolvedValue({
result: agentPaths({ action_dir: '/Users/sample/projects', action_dir_source: 'default' }),
logs: [],
});
renderWithProviders(<PermissionsPanel />);
expect(await screen.findByTestId('permissions-action-dir-edit')).toBeInTheDocument();
expect(screen.queryByTestId('permissions-action-dir-env-locked')).not.toBeInTheDocument();
});
it('saving a new action_dir calls openhumanUpdateAgentPaths and updates the display', async () => {
mockGetPaths.mockResolvedValue({
result: agentPaths({ action_dir: '/Users/sample/old', action_dir_source: 'default' }),
logs: [],
});
mockUpdatePaths.mockResolvedValue({
result: agentPaths({ action_dir: '/Users/sample/new', action_dir_source: 'override' }),
logs: [],
});
renderWithProviders(<PermissionsPanel />);
fireEvent.click(await screen.findByTestId('permissions-action-dir-edit'));
const input = await screen.findByTestId('permissions-action-dir-input');
fireEvent.change(input, { target: { value: '/Users/sample/new' } });
fireEvent.click(screen.getByTestId('permissions-action-dir-save'));
await waitFor(() =>
expect(mockUpdatePaths).toHaveBeenCalledWith({ action_dir: '/Users/sample/new' })
);
expect(await screen.findByTestId('permissions-action-dir')).toHaveTextContent(
'/Users/sample/new'
);
});
it('renders a backend validation error inline without leaving edit mode', async () => {
mockGetPaths.mockResolvedValue({
result: agentPaths({ action_dir: '/Users/sample/old', action_dir_source: 'default' }),
logs: [],
});
mockUpdatePaths.mockRejectedValue(new Error('action_dir must be an absolute path'));
renderWithProviders(<PermissionsPanel />);
fireEvent.click(await screen.findByTestId('permissions-action-dir-edit'));
const input = await screen.findByTestId('permissions-action-dir-input');
fireEvent.change(input, { target: { value: 'relative/path' } });
fireEvent.click(screen.getByTestId('permissions-action-dir-save'));
expect(await screen.findByTestId('permissions-action-dir-error')).toHaveTextContent(
'action_dir must be an absolute path'
);
expect(screen.getByTestId('permissions-action-dir-input')).toBeInTheDocument();
});
it('disables editing and shows the env-locked notice when source is env', async () => {
mockGetPaths.mockResolvedValue({
result: agentPaths({ action_dir: '/tmp/env-pinned', action_dir_source: 'env' }),
logs: [],
});
renderWithProviders(<PermissionsPanel />);
await screen.findByTestId('permissions-action-dir');
expect(screen.queryByTestId('permissions-action-dir-edit')).not.toBeInTheDocument();
expect(screen.getByTestId('permissions-action-dir-env-locked')).toBeInTheDocument();
});
it('shows the desktop-only notice and skips loading off-Tauri', async () => {
vi.mocked(isTauri).mockReturnValue(false);
renderWithProviders(<PermissionsPanel />);
expect(
await screen.findByText('Access settings are only available in the desktop app.')
).toBeInTheDocument();
expect(mockGet).not.toHaveBeenCalled();
expect(mockGetPaths).not.toHaveBeenCalled();
});
it('surfaces a load error without crashing', async () => {
mockGet.mockRejectedValue(new Error('load failed'));
renderWithProviders(<PermissionsPanel />);
expect(await screen.findByText('load failed')).toBeInTheDocument();
});
it('carries workspace_only and trusted_roots through when persisting a tier change', async () => {
mockGet.mockResolvedValue({
result: autonomy({
level: 'supervised',
workspace_only: true,
trusted_roots: [{ path: '/tmp/proj', access: 'read' }],
}),
logs: [],
});
renderWithProviders(<PermissionsPanel />);
fireEvent.click(await screen.findByText("Look, don't touch"));
await waitFor(() =>
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
level: 'readonly',
workspace_only: true,
trusted_roots: [{ path: '/tmp/proj', access: 'read' }],
})
)
);
});
});
@@ -551,7 +551,7 @@ describe('createWalkthroughSteps', () => {
const navigate = vi.fn();
const steps = createWalkthroughSteps(navigate);
// Steps: 2=chat, 3=integrations, 4=channels, 5=intelligence, 6=settings, 7=home-return, 9=chat-welcome
// Steps: 2=chat, 3=integrations, 4=channels, 5=activity, 6=settings, 7=home-return, 9=chat-welcome
const crossPageIndices = [2, 3, 4, 5, 6, 7, 9];
for (const idx of crossPageIndices) {
expect(typeof steps[idx].before, `step[${idx}] should have a before fn`).toBe('function');
@@ -571,9 +571,9 @@ describe('createWalkthroughSteps', () => {
it.each([
{ idx: 2, route: '/chat', target: 'chat-agent-panel' },
{ idx: 3, route: '/skills', target: 'skills-grid' },
{ idx: 3, route: '/connections', target: 'skills-grid' },
{ idx: 4, route: null, target: 'skills-channels' },
{ idx: 5, route: '/intelligence', target: 'intelligence-header' },
{ idx: 5, route: '/activity', target: 'intelligence-header' },
{ idx: 6, route: '/settings', target: 'settings-menu' },
{ idx: 7, route: '/home', target: 'tab-chat' },
{ idx: 9, route: '/chat', target: 'chat-agent-panel' },
@@ -87,7 +87,7 @@ export function createWalkthroughSteps(navigate: NavigateFunction): Step[] {
},
},
// ── Step 4 — /skills ──────────────────────────────────────────────────
// ── Step 4 — /connections (Apps tab) ─────────────────────────────────
{
target: '[data-walkthrough="skills-grid"]',
title: 'Connect your world',
@@ -96,12 +96,12 @@ export function createWalkthroughSteps(navigate: NavigateFunction): Step[] {
placement: 'top',
skipBeacon: true,
before: async () => {
navigate('/skills');
navigate('/connections');
await waitForTarget('skills-grid');
},
},
// ── Step 5 — /skills (channels) ─────────────────────────────────────
// ── Step 5 — /connections (Messaging tab) ────────────────────────────
{
target: '[data-walkthrough="skills-channels"]',
title: 'Chat where you already are',
@@ -114,16 +114,16 @@ export function createWalkthroughSteps(navigate: NavigateFunction): Step[] {
},
},
// ── Step 6 — /intelligence ────────────────────────────────────────────
// ── Step 6 — /activity (Activity) ────────────────────────────────────
{
target: '[data-walkthrough="intelligence-header"]',
title: "Your assistant's brain",
title: 'Your activity hub',
content:
'This is where your assistant learns and remembers. It gets smarter the more you use it.',
placement: 'bottom',
skipBeacon: true,
before: async () => {
navigate('/intelligence');
navigate('/activity');
await waitForTarget('intelligence-header');
},
},
@@ -157,7 +157,7 @@ export function createWalkthroughSteps(navigate: NavigateFunction): Step[] {
// ── Step 8 — /home (already there) ───────────────────────────────────
{
target: '[data-walkthrough="tab-notifications"]',
target: '[data-walkthrough="tab-activity"]',
title: 'Stay in the loop',
content: 'Alerts and automations live here — briefings, notifications, background activity.',
placement: 'top',
+126
View File
@@ -0,0 +1,126 @@
/**
* Tests for navConfig — verifies the shape, count, and key values of NAV_TABS
* and AVATAR_MENU_ITEMS so regressions are caught early.
*
* Phase 6 update: Human tab removed; Chat tab renamed to "Assistant"
* (id stays 'chat', labelKey 'nav.assistant', walkthroughAttr 'tab-chat').
* Nav drops from 6 tabs to 5.
*/
import { describe, expect, it } from 'vitest';
import { AVATAR_MENU_ITEMS, NAV_TABS } from '../navConfig';
describe('NAV_TABS', () => {
it('has exactly 5 entries (Phase 6: Human merged into Assistant)', () => {
expect(NAV_TABS).toHaveLength(5);
});
it('has the correct ids in order', () => {
expect(NAV_TABS.map(t => t.id)).toEqual([
'home',
'chat',
'connections',
'activity',
'settings',
]);
});
it('has the correct paths', () => {
expect(NAV_TABS.map(t => t.path)).toEqual([
'/home',
'/chat',
'/connections',
'/activity',
'/settings',
]);
});
it('has the correct labelKeys', () => {
expect(NAV_TABS.map(t => t.labelKey)).toEqual([
'nav.home',
'nav.assistant',
'nav.connections',
'nav.activity',
'nav.settings',
]);
});
it('has the correct walkthroughAttrs', () => {
expect(NAV_TABS.map(t => t.walkthroughAttr)).toEqual([
'tab-home',
'tab-chat',
'tab-connections',
'tab-activity',
'tab-settings',
]);
});
it('does not contain a Human tab (Phase 6: merged into Assistant)', () => {
expect(NAV_TABS.find(t => t.id === 'human')).toBeUndefined();
});
it('does not contain a rewards tab', () => {
expect(NAV_TABS.find(t => t.id === 'rewards')).toBeUndefined();
});
it('does not contain an intelligence or skills tab id', () => {
expect(NAV_TABS.find(t => t.id === 'intelligence')).toBeUndefined();
expect(NAV_TABS.find(t => t.id === 'skills')).toBeUndefined();
});
it('Assistant tab uses nav.assistant label key and tab-chat walkthrough attr', () => {
const assistantTab = NAV_TABS.find(t => t.id === 'chat');
expect(assistantTab).toBeDefined();
expect(assistantTab?.labelKey).toBe('nav.assistant');
expect(assistantTab?.walkthroughAttr).toBe('tab-chat');
expect(assistantTab?.path).toBe('/chat');
});
});
describe('AVATAR_MENU_ITEMS', () => {
it('has exactly 5 entries', () => {
expect(AVATAR_MENU_ITEMS).toHaveLength(5);
});
it('has the correct ids in order', () => {
expect(AVATAR_MENU_ITEMS.map(i => i.id)).toEqual([
'account',
'billing',
'rewards',
'invites',
'wallet',
]);
});
it('has the correct labelKeys', () => {
expect(AVATAR_MENU_ITEMS.map(i => i.labelKey)).toEqual([
'nav.avatarMenu.account',
'nav.avatarMenu.billing',
'nav.avatarMenu.rewards',
'nav.avatarMenu.invites',
'nav.avatarMenu.wallet',
]);
});
it('billing, rewards, and invites are cloudOnly; account and wallet are not', () => {
const cloudOnly = AVATAR_MENU_ITEMS.filter(i => i.cloudOnly).map(i => i.id);
expect(cloudOnly).toEqual(['billing', 'rewards', 'invites']);
const notCloudOnly = AVATAR_MENU_ITEMS.filter(i => !i.cloudOnly).map(i => i.id);
expect(notCloudOnly).toEqual(['account', 'wallet']);
});
it('billing uses openUrl; all others use navigate', () => {
const openUrlItems = AVATAR_MENU_ITEMS.filter(i => i.kind === 'openUrl').map(i => i.id);
expect(openUrlItems).toEqual(['billing']);
const navigateItems = AVATAR_MENU_ITEMS.filter(i => i.kind === 'navigate').map(i => i.id);
expect(navigateItems).toEqual(['account', 'rewards', 'invites', 'wallet']);
});
it('each item has a non-empty target', () => {
for (const item of AVATAR_MENU_ITEMS) {
expect(item.target.length).toBeGreaterThan(0);
}
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* Single source of truth for bottom-tab-bar navigation entries and the
* avatar-menu items that appear in the agent-profile popover.
*
* This module is pure data — no JSX, no React imports. Icons are owned by
* BottomTabBar.tsx and mapped from tab.id.
*/
// ── Tab bar ──────────────────────────────────────────────────────────────────
export interface NavTab {
/** Stable identifier used for analytics, icon-maps, and walkthrough attrs. */
id: string;
/** i18n key resolved by `useT()` in the consuming component. */
labelKey: string;
/** Hash-router path this tab navigates to. */
path: string;
/** Value of `data-walkthrough` attribute on the rendered button, if any. */
walkthroughAttr?: string;
}
/**
* Ordered list of bottom-bar tabs. Exactly 5 entries (Phase 6: Human merged
* into Assistant):
* home → chat (Assistant) → connections → activity → settings
*
* The tab id stays `chat` and walkthroughAttr stays `tab-chat` for
* back-compat with analytics and the walkthrough tour. The Human tab has been
* retired; `/human` redirects to `/chat`.
*/
export const NAV_TABS: NavTab[] = [
{ id: 'home', labelKey: 'nav.home', path: '/home', walkthroughAttr: 'tab-home' },
{ id: 'chat', labelKey: 'nav.assistant', path: '/chat', walkthroughAttr: 'tab-chat' },
{
id: 'connections',
labelKey: 'nav.connections',
path: '/connections',
walkthroughAttr: 'tab-connections',
},
{ id: 'activity', labelKey: 'nav.activity', path: '/activity', walkthroughAttr: 'tab-activity' },
{ id: 'settings', labelKey: 'nav.settings', path: '/settings', walkthroughAttr: 'tab-settings' },
];
// ── Avatar / account menu ─────────────────────────────────────────────────────
/**
* Determines how the menu item is activated.
* - `navigate` — internal `react-router-dom` navigation to `target`.
* - `openUrl` — opens `target` in the system browser via `openUrl()`.
*/
export type AvatarMenuItemKind = 'navigate' | 'openUrl';
export interface AvatarMenuItem {
/** Stable identifier. */
id: string;
/** i18n key resolved by the consuming component. */
labelKey: string;
/** Navigation destination or external URL, depending on `kind`. */
target: string;
/** How the item is activated. */
kind: AvatarMenuItemKind;
/**
* When `true`, the item should only be shown for non-local (cloud) sessions.
* Cloud-gated items: billing, rewards, invites.
*/
cloudOnly?: boolean;
}
/**
* Avatar dropdown menu items shown beneath the agent-profile list.
* Order: Account → Billing → Rewards → Invites → Wallet.
*/
export const AVATAR_MENU_ITEMS: AvatarMenuItem[] = [
{
id: 'account',
labelKey: 'nav.avatarMenu.account',
target: '/settings/account',
kind: 'navigate',
},
{
id: 'billing',
labelKey: 'nav.avatarMenu.billing',
// Resolved at runtime via BILLING_DASHBOARD_URL; placeholder keeps typing clean.
target: 'https://tinyhumans.ai/dashboard',
kind: 'openUrl',
cloudOnly: true,
},
{
id: 'rewards',
labelKey: 'nav.avatarMenu.rewards',
target: '/rewards',
kind: 'navigate',
cloudOnly: true,
},
{
id: 'invites',
labelKey: 'nav.avatarMenu.invites',
target: '/invites',
kind: 'navigate',
cloudOnly: true,
},
{
id: 'wallet',
labelKey: 'nav.avatarMenu.wallet',
target: '/settings/wallet-balances',
kind: 'navigate',
},
];
+18
View File
@@ -0,0 +1,18 @@
/**
* useDeveloperMode — runtime developer-mode gate.
*
* Returns `true` when developer surfaces should be visible. The gate is open
* when EITHER the build is a Vite dev build (`IS_DEV`) OR the user has
* enabled Developer Mode in Settings About.
*
* Gating is UI-only. The Rust `SecurityPolicy` / autonomy-tier enforcement
* in the core is authoritative and is never relaxed by this toggle.
*/
import { useAppSelector } from '../store/hooks';
import { selectDeveloperMode } from '../store/themeSlice';
import { IS_DEV } from '../utils/config';
export function useDeveloperMode(): boolean {
const persistedMode = useAppSelector(selectDeveloperMode);
return IS_DEV || persistedMode;
}
@@ -17,11 +17,18 @@ afterEach(() => {
});
describe('registerGlobalActions', () => {
it('registers the 5 seed nav actions into the global frame', () => {
it('registers the 6 seed nav actions into the global frame', () => {
const frame = hotkeyManager.pushFrame('global', 'root');
const navigate = vi.fn() as unknown as NavigateFunction;
registerGlobalActions(navigate, frame);
const ids = ['nav.home', 'nav.chat', 'nav.intelligence', 'nav.skills', 'nav.settings'];
const ids = [
'nav.home',
'nav.chat',
'nav.intelligence',
'nav.skills',
'nav.activity',
'nav.settings',
];
for (const id of ids) expect(registry.getAction(id)?.id).toBe(id);
expect(registry.getAction('help.show')).toBeUndefined();
});
+13 -5
View File
@@ -32,19 +32,27 @@ export function registerGlobalActions(
},
{
id: 'nav.intelligence',
label: 'Go to Intelligence',
label: 'Go to Knowledge & Memory',
group: 'Navigation',
shortcut: 'mod+3',
handler: nav('/settings/intelligence'),
keywords: ['memory', 'knowledge'],
keywords: ['memory', 'knowledge', 'intelligence'],
},
{
id: 'nav.skills',
label: 'Go to Skills',
label: 'Go to Connections',
group: 'Navigation',
shortcut: 'mod+4',
handler: nav('/skills'),
keywords: ['plugins', 'tools'],
handler: nav('/connections'),
keywords: ['plugins', 'tools', 'connections', 'apps', 'skills'],
},
{
id: 'nav.activity',
label: 'Go to Activity',
group: 'Navigation',
shortcut: 'mod+5',
handler: nav('/activity'),
keywords: ['tasks', 'automations', 'alerts', 'background'],
},
{
id: 'nav.settings',
+111 -5
View File
@@ -6,6 +6,11 @@ const messages: TranslationMap = {
'nav.home': 'الرئيسية',
'nav.human': 'إنسان',
'nav.chat': 'المحادثة',
'nav.assistant': 'المساعد',
'assistant.faceMode.on': 'يتحدث إلى Tiny',
'assistant.faceMode.off': 'تحدث إلى Tiny',
'assistant.faceMode.turnOn': 'عرض الوجه',
'assistant.faceMode.turnOff': 'إخفاء الوجه',
'nav.connections': 'الاتصالات',
'nav.memory': 'الذكاء',
'nav.alerts': 'التنبيهات',
@@ -15,6 +20,12 @@ const messages: TranslationMap = {
'nav.switchAgentProfile': 'تبديل ملف الوكيل',
'nav.defaultAgentProfile': 'الوكيل الافتراضي',
'nav.noAgentProfiles': 'لم يتم العثور على ملفات وكلاء',
'nav.activity': 'النشاط',
'nav.avatarMenu.account': 'الحساب',
'nav.avatarMenu.billing': 'الفواتير',
'nav.avatarMenu.rewards': 'المكافآت',
'nav.avatarMenu.invites': 'دعوة صديق',
'nav.avatarMenu.wallet': 'المحفظة',
'common.cancel': 'إلغاء',
'common.save': 'حفظ',
'common.confirm': 'تأكيد',
@@ -59,6 +70,50 @@ const messages: TranslationMap = {
'common.comingSoon': 'قريبًا',
'common.breadcrumb': 'مسار التنقل',
'settings.general': 'عام',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'الحساب',
'settings.groups.assistant': 'المساعد',
'settings.groups.privacySecurity': 'الخصوصية والأمان',
'settings.groups.notifications': 'الإشعارات',
'settings.groups.about': 'حول',
'settings.assistant.personality': 'الشخصية',
'settings.assistant.personalityDesc': 'الاسم والوصف وشخصية SOUL.md',
'settings.assistant.voice': 'الصوت',
'settings.assistant.voiceDesc': 'إعدادات التحويل من كلام إلى نص ومن نص إلى كلام',
'settings.assistant.faceMascot': 'الوجه / الشخصية',
'settings.assistant.faceMascotDesc': 'اختر لون الشخصية المستخدم في التطبيق',
'settings.assistant.backgroundActivity': 'النشاط في الخلفية',
'settings.assistant.backgroundActivityDesc': 'تحكم في مدى نشاط مساعدك في الخلفية',
'settings.assistant.screenAwareness': 'الوعي بالشاشة',
'settings.assistant.screenAwarenessDesc': 'اسمح للمساعد برؤية النافذة النشطة',
'settings.assistant.desktopCompanion': 'المرافق لسطح المكتب',
'settings.assistant.desktopCompanionDesc': 'وضع المرافق الدائم مع اختصار شريط النظام',
'settings.assistant.permissions': 'الأذونات',
'settings.assistant.permissionsDesc': 'اختر ما يمكن للمساعد فعله والمكان الذي يمكنه العمل فيه',
'settings.privacySecurity.privacy': 'الخصوصية',
'settings.privacySecurity.privacyDesc': 'تحكم في البيانات التي تغادر جهازك',
'settings.privacySecurity.security': 'الأمان',
'settings.privacySecurity.securityDesc': 'الجلسات وخيارات تسجيل الدخول',
'settings.privacySecurity.approvalsHistory': 'الموافقات والسجل',
'settings.privacySecurity.approvalsHistoryDesc': 'مراجعة قرارات الموافقة الأخيرة على الأدوات',
'settings.notifications.menuTitle': 'الإشعارات',
'settings.notifications.menuDesc': 'صندوق التنبيهات وتفضيلات الإشعارات',
'settings.devGroups.knowledgeMemory': 'المعرفة والذاكرة',
'settings.devGroups.agentsAutonomy': 'الوكلاء والاستقلالية',
'settings.devGroups.modelsInference': 'النماذج والاستنتاج',
'settings.devGroups.automationIntegrations': 'الأتمتة والتكاملات',
'settings.devGroups.toolsCapabilities': 'الأدوات والقدرات',
'settings.devGroups.council': 'المجلس',
'settings.analysisViews.title': 'طرق العرض التحليلية',
'settings.analysisViews.menuDesc':
'تحليل رسم الذاكرة البياني — المخطط، والمركزية، والتماسك، والارتباطات، والحداثة، والجدول الزمني، والمسارات، والمساحات',
'settings.buildInfo.title': 'معلومات الإصدار / البناء',
'settings.buildInfo.menuDesc': 'تفاصيل بناء التطبيق وإصداره واتصال النواة',
'settings.dataSync.title': 'مزامنة البيانات',
'settings.dataSync.menuDesc': 'ما يقوم مساعدك بمزامنته — المصادر والحداثة والحالة',
'settings.dataSync.description':
'تحكم في ما تتم مزامنته في ذاكرة مساعدك: كل مصدر متصل مع وقت آخر مزامنة له، ومقدار ما تمت مزامنته، وما إذا كان قيد المزامنة الآن.',
'settings.devGroups.diagnosticsLogs': 'التشخيصات والسجلات',
'settings.featuresAndAI': 'الميزات والذكاء الاصطناعي',
'settings.billingAndRewards': 'الفوترة والمكافآت',
'settings.support': 'الدعم',
@@ -87,6 +142,12 @@ const messages: TranslationMap = {
'settings.developerOptions': 'متقدم',
'settings.developerOptionsDesc':
'إعداد الذكاء الاصطناعي وقنوات المراسلة والأدوات والتشخيص ولوحات التصحيح',
'settings.developerDiagnostics': 'المطور والتشخيص',
'settings.developerDiagnosticsDesc':
'أدوات المطور المتقدمة والتشخيص والذاكرة والوكلاء ولوحات التصحيح',
'settings.developerMode.title': 'وضع المطور',
'settings.developerMode.description': 'إظهار أدوات المطور والتشخيص المتقدمة',
'settings.developerMode.enabledByBuild': 'مفعّل دائمًا في إصدارات التطوير',
'settings.clearAppData': 'مسح بيانات التطبيق',
'settings.clearAppDataDesc': 'تسجيل الخروج وحذف جميع البيانات المحلية للتطبيق نهائيًا',
'settings.logOut': 'تسجيل الخروج',
@@ -98,6 +159,14 @@ const messages: TranslationMap = {
'settings.languageDesc': 'لغة عرض واجهة التطبيق',
'settings.alerts': 'التنبيهات',
'settings.alertsDesc': 'عرض التنبيهات الأخيرة والنشاط في صندوق الوارد',
'settings.account.profile': 'الملف الشخصي',
'settings.account.profileDesc': 'الاسم والبريد الإلكتروني والصورة الرمزية',
'settings.account.devices': 'الأجهزة',
'settings.account.devicesDesc': 'إقران الأجهزة المحمولة وإدارتها',
'settings.account.teamMembers': 'الفريق والأعضاء',
'settings.account.teamMembersDesc': 'إدارة وصول الفريق وأدوار الأعضاء',
'settings.account.dataMigration': 'البيانات والترحيل',
'settings.account.dataMigrationDesc': 'استيراد الذاكرة من مساعد آخر',
'settings.account.recoveryPhrase': 'عبارة الاسترداد',
'settings.account.recoveryPhraseDesc': 'عرض عبارة استرداد الحساب ونسخها احتياطيًا',
'settings.account.team': 'الفريق',
@@ -257,7 +326,7 @@ const messages: TranslationMap = {
'skills.connected': 'متصل',
'skills.available': 'متاح',
'skills.addAccount': 'إضافة حساب',
'skills.channels': 'القنوات',
'skills.channels': 'المراسلة',
'skills.explorer.emptyCta': 'التثبيت من رابط',
'skills.explorer.emptyDescription':
'ثبّت حزمة SKILL.md أو ضع مجلدات بنمط Hermes داخل ~/.openhuman/skills.',
@@ -284,9 +353,9 @@ const messages: TranslationMap = {
'skills.explorer.installed': 'مثبت',
'skills.explorer.install': 'تثبيت',
'skills.explorer.installing': 'جارٍ التثبيت…',
'skills.integrations': 'التكاملات',
'skills.integrations': 'التطبيقات',
'skills.integrationsSubtitle':
'اتصالات OAuth السحابية — سجّل الدخول بحسابك ويتولى Composio إدارة الرموز حتى يتمكن الوكلاء من القراءة والتصرف نيابةً عنك. لا حاجة لإدارة مفاتيح API.',
'اتصالات OAuth السحابية — سجّل الدخول بحسابك وتُدار الرموز بأمان حتى يتمكن الوكلاء من القراءة والتصرف نيابةً عنك. لا حاجة لإدارة مفاتيح API.',
'skills.composio.noApiKeyTitle': 'لم يتم إعداد مفتاح Composio API',
'skills.composio.noApiKeyDescription':
'يستخدم الوضع المحلي مفتاح Composio API الخاص بك. افتح الإعدادات ← الخيارات المتقدمة ← Composio لإضافته قبل توصيل التكاملات هنا.',
@@ -296,6 +365,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'المهارات',
'skills.tabs.meetings': 'اجتماعات Google Meet',
'skills.tabs.mcp': 'MCP الخوادم',
'connections.tabs.apps': 'التطبيقات',
'connections.tabs.messaging': 'المراسلة',
'connections.tabs.tools': 'الأدوات',
'connections.tabs.explorer': 'المستكشف',
'connections.tabs.talents': 'المواهب',
'memory.title': 'الذاكرة',
'memory.search': 'البحث في الذكريات...',
'memory.noResults': 'لم يتم العثور على ذكريات',
@@ -685,6 +759,7 @@ const messages: TranslationMap = {
'team.failedChangeRole': 'فشل تغيير الدور',
'team.failedRemoveMember': 'فشلت إزالة العضو',
'devOptions.title': 'متقدم',
'devOptions.titleDiagnostics': 'المطور والتشخيص',
'devOptions.diagnostics': 'التشخيص',
'devOptions.diagnosticsDesc': 'صحة النظام والسجلات ومقاييس الأداء',
'devOptions.toolPolicyDiagnosticsDesc':
@@ -1780,7 +1855,7 @@ const messages: TranslationMap = {
'common.enable': 'تفعيل',
'chat.safetyTimeout': 'لا استجابة من الوكيل بعد دقيقتين. حاول مرة أخرى أو تحقق من اتصالك.',
'chat.filter.general': 'عام',
'chat.filter.subconscious': 'اللاوعي',
'chat.filter.subconscious': 'نشاط الخلفية',
'chat.filter.meetings': 'الاجتماعات',
'chat.filter.tasks': 'المهام',
'chat.selectThread': 'اختر محادثة',
@@ -3757,6 +3832,11 @@ const messages: TranslationMap = {
'settings.developerMenu.mcpServer.desc': 'تكوين عملاء MCP الخارجيين للاتصال بـ OpenHuman',
'settings.developerMenu.autonomy.title': 'استقلالية الوكيل',
'settings.developerMenu.autonomy.desc': 'حدود معدل إجراءات الأدوات وعتبات الأمان',
'settings.developerMenu.autocomplete.title': 'الإكمال التلقائي',
'settings.developerMenu.autocomplete.desc':
'إعدادات الإكمال التلقائي المدعوم بالذكاء الاصطناعي ولوحة التصحيح',
'settings.developerMenu.voiceDebug.title': 'الصوت (تصحيح)',
'settings.developerMenu.voiceDebug.desc': 'حالة وقت التشغيل لإملاء الصوت وإعدادات التصحيح',
'settings.mcpServer.title': 'Xqx0x',
'settings.mcpServer.toolsSectionTitle': 'الأدوات المتاحة',
'settings.mcpServer.toolsSectionDesc':
@@ -3854,6 +3934,26 @@ const messages: TranslationMap = {
'settings.agentAccess.approvalHistoryDesc':
'مراجعة قرارات الموافقة / الرفض السابقة التي طلبها الوكيل.',
'settings.agentAccess.viewApprovalHistory': 'عرض سجل الموافقات',
// ── لوحة الأذونات ────────────────────────────────────────────────
'settings.permissions.title': 'الأذونات',
'settings.permissions.menuDesc': 'اختر ما يمكن للمساعد فعله والمكان الذي يمكنه العمل فيه.',
'settings.permissions.accessMode': 'ماذا يمكن للمساعد أن يفعل؟',
'settings.permissions.accessModeDesc':
'اختر مقدار الحرية التي يتمتع بها المساعد عند اتخاذ الإجراءات على جهاز الكمبيوتر الخاص بك.',
'settings.permissions.preset.readonly.title': 'انظر ولا تلمس',
'settings.permissions.preset.readonly.desc':
'يمكن للمساعد قراءة الملفات والاستكشاف — لكنه لن يكتب أو يعدّل أو يُشغّل أي شيء يغير الحالة.',
'settings.permissions.preset.supervised.title': 'اسألني أولاً',
'settings.permissions.preset.supervised.desc':
'يمكنه إنشاء ملفات جديدة بحرية، لكنه يطلب موافقتك دائمًا قبل التعديل أو تشغيل الأوامر أو الوصول إلى الشبكة.',
'settings.permissions.preset.full.title': 'تحكم كامل',
'settings.permissions.preset.full.desc':
'يعمل بصلاحيات حسابك الكاملة. الأوامر المدمرة والوصول إلى الشبكة والتثبيت لا تزال تطلب الموافقة.',
'settings.permissions.folders': 'أين يمكنه العمل؟',
'settings.permissions.foldersDesc':
'المجلد الافتراضي الذي يقرأ ويكتب فيه المساعد. يمكنك إضافة مجلدات أخرى في الإعدادات المتقدمة.',
'settings.sandbox.title': 'تنفيذ Sandbox',
'settings.sandbox.menuDesc': 'تكوين خلفيات Sandbox لعزل أدوات الوكيل.',
'settings.sandbox.loading': 'جارٍ التحميل…',
@@ -4067,7 +4167,7 @@ const messages: TranslationMap = {
'skills.channelIcon.telegram': 'Telegram',
'skills.channelIcon.web': 'الويب',
'skills.channelIcon.yuanbao': 'Yuanbao',
'skills.composio.poweredBy': 'مدعوم من Composio',
'skills.composio.poweredBy': 'OAuth',
'skills.composio.staleStatusTitle': 'تظهر الاتصالات حالة قديمة',
'skills.create.allowedTools': 'الأدوات المسموح بها',
'skills.create.allowedToolsHelp': 'تم عرضها في المادة الأمامية SKILL.md كـ',
@@ -4685,6 +4785,12 @@ const messages: TranslationMap = {
'autocomplete.maxChars': 'أقصى عدد لأحرف السياق',
'autocomplete.overlayTtlMs': 'مهلة الطبقة (مللي ثانية)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'أتمتة',
'activity.tabs.automationsDescription':
'إجراءات قابلة لإعادة الاستخدام والتشغيل — هدف مع الخطوات للوصول إليه.',
'activity.tabs.backgroundActivity': 'نشاط الخلفية',
'activity.tabs.alerts': 'التنبيهات',
'intelligence.agents.title': 'مكتبة الوكلاء',
'intelligence.agents.subtitle':
'استعرض المتخصصين القابلين للتشغيل وأرسل مهمة واحدة إلى وكيل محدد.',
+113 -5
View File
@@ -6,6 +6,11 @@ const messages: TranslationMap = {
'nav.home': 'হোম',
'nav.human': 'হিউম্যান',
'nav.chat': 'চ্যাট',
'nav.assistant': 'সহকারী',
'assistant.faceMode.on': 'Tiny-র সাথে কথা বলছে',
'assistant.faceMode.off': 'Tiny-র সাথে কথা বলুন',
'assistant.faceMode.turnOn': 'মাসকট দেখান',
'assistant.faceMode.turnOff': 'মাসকট লুকান',
'nav.connections': 'সংযোগ',
'nav.memory': 'ইন্টেলিজেন্স',
'nav.alerts': 'সতর্কতা',
@@ -15,6 +20,12 @@ const messages: TranslationMap = {
'nav.switchAgentProfile': 'এজেন্ট প্রোফাইল বদলান',
'nav.defaultAgentProfile': 'ডিফল্ট এজেন্ট',
'nav.noAgentProfiles': 'কোনো এজেন্ট প্রোফাইল পাওয়া যায়নি',
'nav.activity': 'কার্যকলাপ',
'nav.avatarMenu.account': 'অ্যাকাউন্ট',
'nav.avatarMenu.billing': 'বিলিং',
'nav.avatarMenu.rewards': 'পুরস্কার',
'nav.avatarMenu.invites': 'বন্ধুকে আমন্ত্রণ জানান',
'nav.avatarMenu.wallet': 'ওয়ালেট',
'common.cancel': 'বাতিল',
'common.save': 'সংরক্ষণ',
'common.confirm': 'নিশ্চিত করুন',
@@ -59,6 +70,52 @@ const messages: TranslationMap = {
'common.comingSoon': 'শীঘ্রই আসছে',
'common.breadcrumb': 'ব্রেডক্রাম্ব',
'settings.general': 'সাধারণ',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'অ্যাকাউন্ট',
'settings.groups.assistant': 'সহকারী',
'settings.groups.privacySecurity': 'গোপনীয়তা ও নিরাপত্তা',
'settings.groups.notifications': 'বিজ্ঞপ্তি',
'settings.groups.about': 'সম্পর্কে',
'settings.assistant.personality': 'ব্যক্তিত্ব',
'settings.assistant.personalityDesc': 'নাম, বিবরণ এবং SOUL.md ব্যক্তিত্ব',
'settings.assistant.voice': 'ভয়েস',
'settings.assistant.voiceDesc': 'স্পিচ-টু-টেক্সট ও টেক্সট-টু-স্পিচ সেটিংস',
'settings.assistant.faceMascot': 'মুখ / মাসকট',
'settings.assistant.faceMascotDesc': 'অ্যাপ জুড়ে ব্যবহৃত মাসকট রঙ বাছুন',
'settings.assistant.backgroundActivity': 'ব্যাকগ্রাউন্ড কার্যকলাপ',
'settings.assistant.backgroundActivityDesc':
'আপনার সহকারী পটভূমিতে কতটা সক্রিয় তা নিয়ন্ত্রণ করুন',
'settings.assistant.screenAwareness': 'স্ক্রিন সচেতনতা',
'settings.assistant.screenAwarenessDesc': 'সহকারীকে আপনার সক্রিয় উইন্ডো দেখতে দিন',
'settings.assistant.desktopCompanion': 'ডেস্কটপ সঙ্গী',
'settings.assistant.desktopCompanionDesc': 'সিস্টেম ট্রে শর্টকাট সহ সর্বদা-চালু সঙ্গী মোড',
'settings.assistant.permissions': 'অনুমতি',
'settings.assistant.permissionsDesc': 'সহকারী কী করতে পারে এবং কোথায় কাজ করতে পারে তা বেছে নিন',
'settings.privacySecurity.privacy': 'গোপনীয়তা',
'settings.privacySecurity.privacyDesc': 'আপনার কম্পিউটার ছেড়ে যাওয়া ডেটা নিয়ন্ত্রণ করুন',
'settings.privacySecurity.security': 'নিরাপত্তা',
'settings.privacySecurity.securityDesc': 'সেশন ও সাইন-ইন বিকল্প',
'settings.privacySecurity.approvalsHistory': 'অনুমোদন ও ইতিহাস',
'settings.privacySecurity.approvalsHistoryDesc':
'সাম্প্রতিক টুল-অনুমোদন সিদ্ধান্ত পর্যালোচনা করুন',
'settings.notifications.menuTitle': 'বিজ্ঞপ্তি',
'settings.notifications.menuDesc': 'সতর্কতা ইনবক্স ও বিজ্ঞপ্তি পছন্দ',
'settings.devGroups.knowledgeMemory': 'জ্ঞান ও স্মৃতি',
'settings.devGroups.agentsAutonomy': 'এজেন্ট ও স্বায়ত্তশাসন',
'settings.devGroups.modelsInference': 'মডেল ও অনুমান',
'settings.devGroups.automationIntegrations': 'অটোমেশন ও ইন্টিগ্রেশন',
'settings.devGroups.toolsCapabilities': 'টুলস ও ক্ষমতা',
'settings.devGroups.council': 'কাউন্সিল',
'settings.analysisViews.title': 'বিশ্লেষণ ভিউ',
'settings.analysisViews.menuDesc':
'মেমরি গ্রাফ বিশ্লেষণ — ডায়াগ্রাম, কেন্দ্রীয়তা, সংহতি, সংযোগ, সতেজতা, টাইমলাইন, পাথ এবং নেমস্পেস',
'settings.buildInfo.title': 'বিল্ড / সংস্করণ তথ্য',
'settings.buildInfo.menuDesc': 'অ্যাপ বিল্ড, সংস্করণ এবং কোর সংযোগের বিবরণ',
'settings.dataSync.title': 'ডেটা সিঙ্ক',
'settings.dataSync.menuDesc': 'আপনার সহকারী যা সিঙ্ক করে — উৎস, সতেজতা এবং স্থিতি',
'settings.dataSync.description':
'আপনার সহকারীর মেমরিতে কী সিঙ্ক হয় তা পরিচালনা করুন: প্রতিটি সংযুক্ত উৎস তার শেষ সিঙ্কের সময়, কতটা সিঙ্ক হয়েছে এবং এখন সিঙ্ক হচ্ছে কিনা সহ।',
'settings.devGroups.diagnosticsLogs': 'ডায়াগনস্টিকস ও লগ',
'settings.featuresAndAI': 'ফিচার ও AI',
'settings.billingAndRewards': 'বিলিং ও পুরস্কার',
'settings.support': 'সহায়তা',
@@ -87,6 +144,12 @@ const messages: TranslationMap = {
'settings.developerOptions': 'অ্যাডভান্সড',
'settings.developerOptionsDesc':
'AI কনফিগারেশন, মেসেজিং চ্যানেল, টুলস, ডায়াগনস্টিক্স এবং ডিবাগ প্যানেল',
'settings.developerDiagnostics': 'ডেভেলপার ও ডায়াগনস্টিক্স',
'settings.developerDiagnosticsDesc':
'উন্নত ডেভেলপার টুলস, ডায়াগনস্টিক্স, মেমোরি, এজেন্ট এবং ডিবাগ প্যানেল',
'settings.developerMode.title': 'ডেভেলপার মোড',
'settings.developerMode.description': 'উন্নত ডেভেলপার ও ডায়াগনস্টিক টুলস দেখান',
'settings.developerMode.enabledByBuild': 'ডেভেলপমেন্ট বিল্ডে সবসময় চালু',
'settings.clearAppData': 'অ্যাপ ডেটা মুছুন',
'settings.clearAppDataDesc': 'সাইন আউট করুন এবং সব লোকাল ডেটা স্থায়ীভাবে মুছুন',
'settings.logOut': 'লগ আউট',
@@ -98,6 +161,14 @@ const messages: TranslationMap = {
'settings.languageDesc': 'অ্যাপ ইন্টারফেসের প্রদর্শন ভাষা',
'settings.alerts': 'সতর্কতা',
'settings.alertsDesc': 'ইনবক্সে সাম্প্রতিক সতর্কতা ও কার্যক্রম দেখুন',
'settings.account.profile': 'প্রোফাইল',
'settings.account.profileDesc': 'নাম, ইমেইল এবং অ্যাভাটার',
'settings.account.devices': 'ডিভাইস',
'settings.account.devicesDesc': 'মোবাইল ডিভাইস পেয়ার করুন ও পরিচালনা করুন',
'settings.account.teamMembers': 'টিম ও সদস্য',
'settings.account.teamMembersDesc': 'টিম অ্যাক্সেস ও সদস্য ভূমিকা পরিচালনা করুন',
'settings.account.dataMigration': 'ডেটা ও মাইগ্রেশন',
'settings.account.dataMigrationDesc': 'অন্য অ্যাসিস্ট্যান্ট থেকে মেমরি আমদানি করুন',
'settings.account.recoveryPhrase': 'রিকভারি ফ্রেজ',
'settings.account.recoveryPhraseDesc': 'আপনার অ্যাকাউন্ট রিকভারি ফ্রেজ দেখুন ও ব্যাকআপ করুন',
'settings.account.team': 'টিম',
@@ -259,7 +330,7 @@ const messages: TranslationMap = {
'skills.connected': 'সংযুক্ত',
'skills.available': 'পাওয়া যাচ্ছে',
'skills.addAccount': 'অ্যাকাউন্ট যোগ করুন',
'skills.channels': 'চ্যানেল',
'skills.channels': 'মেসেজিং',
'skills.explorer.emptyCta': 'URL থেকে ইনস্টল করুন',
'skills.explorer.emptyDescription':
'একটি SKILL.md প্যাকেজ ইনস্টল করুন বা Hermes-ধাঁচের ফোল্ডার ~/.openhuman/skills-এ রাখুন।',
@@ -287,9 +358,9 @@ const messages: TranslationMap = {
'skills.explorer.installed': 'ইনস্টল করা হয়েছে',
'skills.explorer.install': 'ইনস্টল করুন',
'skills.explorer.installing': 'ইনস্টল হচ্ছে…',
'skills.integrations': 'ইন্টিগ্রেশন',
'skills.integrations': 'অ্যাপস',
'skills.integrationsSubtitle':
'ক্লাউড-ভিত্তিক OAuth সংযোগ — আপনার অ্যাকাউন্ট দিয়ে সাইন ইন করুন এবং Composio টোকেন পরিচালনা করে যাতে এজেন্টরা আপনার পক্ষে পড়তে এবং কাজ করতে পারে। কোনো API কী পরিচালনা করতে হবে না।',
'ক্লাউড-ভিত্তিক OAuth সংযোগ — আপনার অ্যাকাউন্ট দিয়ে সাইন ইন করুন এবং টোকেন নিরাপদে পরিচালিত হয় যাতে এজেন্টরা আপনার পক্ষে পড়তে এবং কাজ করতে পারে। কোনো API কী পরিচালনা করতে হবে না।',
'skills.composio.noApiKeyTitle': 'কোনো Composio API Key কনফিগার করা নেই',
'skills.composio.noApiKeyDescription':
'লোকাল মোডে আপনার নিজের Composio API key ব্যবহার হয়। এখানে ইন্টিগ্রেশন যুক্ত করার আগে Settings → Advanced → Composio খুলে একটি key যোগ করুন।',
@@ -299,6 +370,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'স্কিল',
'skills.tabs.meetings': 'Google Meet মিটিং',
'skills.tabs.mcp': 'MCP সার্ভার',
'connections.tabs.apps': 'অ্যাপস',
'connections.tabs.messaging': 'বার্তা পাঠানো',
'connections.tabs.tools': 'সরঞ্জাম',
'connections.tabs.explorer': 'এক্সপ্লোরার',
'connections.tabs.talents': 'ট্যালেন্ট',
'memory.title': 'মেমোরি',
'memory.search': 'মেমোরি খুঁজুন...',
'memory.noResults': 'কোনো মেমোরি পাওয়া যায়নি',
@@ -702,6 +778,7 @@ const messages: TranslationMap = {
'team.failedChangeRole': 'ভূমিকা পরিবর্তন করতে ব্যর্থ হয়েছে',
'team.failedRemoveMember': 'সদস্য সরাতে ব্যর্থ হয়েছে',
'devOptions.title': 'অ্যাডভান্সড',
'devOptions.titleDiagnostics': 'ডেভেলপার ও ডায়াগনস্টিক্স',
'devOptions.diagnostics': 'ডায়াগনস্টিক্স',
'devOptions.diagnosticsDesc': 'সিস্টেম স্বাস্থ্য, লগ এবং পারফরম্যান্স মেট্রিক্স',
'devOptions.toolPolicyDiagnosticsDesc': 'টুল- মেকআপ, নীতি পোস্ট, xqxqxs, এবং সম্প্রতি ব্যবহৃত',
@@ -1821,7 +1898,7 @@ const messages: TranslationMap = {
'chat.safetyTimeout':
'২ মিনিট পরেও এজেন্টের কোনো সাড়া নেই। আবার চেষ্টা করুন বা সংযোগ পরীক্ষা করুন।',
'chat.filter.general': 'সাধারণ',
'chat.filter.subconscious': 'সাবকনশাস',
'chat.filter.subconscious': 'ব্যাকগ্রাউন্ড কার্যকলাপ',
'chat.filter.meetings': 'মিটিং',
'chat.filter.tasks': 'টাস্ক',
'chat.selectThread': 'একটি থ্রেড বেছে নিন',
@@ -3833,6 +3910,10 @@ const messages: TranslationMap = {
'বাহ্যিক MCP ক্লায়েন্টগুলিকে OpenHuman-এ সংযুক্ত করতে কনফিগার করুন',
'settings.developerMenu.autonomy.title': 'এজেন্ট স্বায়ত্তশাসন',
'settings.developerMenu.autonomy.desc': 'টুল অ্যাকশনের রেট সীমা এবং নিরাপত্তা থ্রেশহোল্ড',
'settings.developerMenu.autocomplete.title': 'স্বয়ংসম্পূর্ণ',
'settings.developerMenu.autocomplete.desc': 'AI ইনলাইন স্বয়ংসম্পূর্ণ সেটিংস এবং ডিবাগ প্যানেল',
'settings.developerMenu.voiceDebug.title': 'ভয়েস (ডিবাগ)',
'settings.developerMenu.voiceDebug.desc': 'ভয়েস ডিক্টেশন রানটাইম স্ট্যাটাস এবং ডিবাগ সেটিংস',
'settings.mcpServer.title':
'MCP সার্ভারের সাথে সংযোগ করতে বহিরাগত __BR1__ ক্লায়েন্ট কনফিগার করুন',
'settings.mcpServer.toolsSectionTitle': 'উপলভ্য টুল',
@@ -3934,6 +4015,27 @@ const messages: TranslationMap = {
'settings.agentAccess.approvalHistoryDesc':
'এজেন্টের অনুরোধ করা পূর্ববর্তী অনুমোদন/প্রত্যাখ্যান সিদ্ধান্ত পর্যালোচনা করুন।',
'settings.agentAccess.viewApprovalHistory': 'অনুমোদনের ইতিহাস দেখুন',
// ── অনুমতি প্যানেল ───────────────────────────────────────────────
'settings.permissions.title': 'অনুমতি',
'settings.permissions.menuDesc':
'আপনার সহকারী কী করতে পারে এবং কোথায় কাজ করতে পারে তা বেছে নিন।',
'settings.permissions.accessMode': 'সহকারী কী করতে পারে?',
'settings.permissions.accessModeDesc':
'আপনার কম্পিউটারে পদক্ষেপ নেওয়ার সময় সহকারীর কতটুকু স্বাধীনতা থাকবে তা বেছে নিন।',
'settings.permissions.preset.readonly.title': 'দেখো, স্পর্শ করো না',
'settings.permissions.preset.readonly.desc':
'সহকারী ফাইল পড়তে এবং অন্বেষণ করতে পারে — কিন্তু কখনো লিখতে, সম্পাদনা করতে বা এমন কিছু চালাতে পারবে না যা অবস্থা পরিবর্তন করে।',
'settings.permissions.preset.supervised.title': 'আগে আমাকে জিজ্ঞেস করো',
'settings.permissions.preset.supervised.desc':
'নতুন ফাইল তৈরি করতে পারে, কিন্তু সম্পাদনা, কমান্ড চালানো বা নেটওয়ার্ক অ্যাক্সেসের আগে সবসময় আপনার অনুমতি চাইবে।',
'settings.permissions.preset.full.title': 'পূর্ণ নিয়ন্ত্রণ',
'settings.permissions.preset.full.desc':
'আপনার পূর্ণ অ্যাকাউন্ট অ্যাক্সেস দিয়ে কাজ করে। ধ্বংসাত্মক কমান্ড, নেটওয়ার্ক অ্যাক্সেস এবং ইনস্টলেশন এখনও অনুমতি চাইবে।',
'settings.permissions.folders': 'এটি কোথায় কাজ করতে পারে?',
'settings.permissions.foldersDesc':
'সহকারী যে ডিফল্ট ফোল্ডার পড়ে এবং লেখে। উন্নত সেটিংসে আপনি আরও ফোল্ডার যোগ করতে পারেন।',
'settings.sandbox.title': 'Sandbox কার্যকরী',
'settings.sandbox.menuDesc': 'এজেন্ট টুল আইসোলেশনের জন্য Sandbox ব্যাকএন্ড কনফিগার করুন।',
'settings.sandbox.loading': 'লোড হচ্ছে…',
@@ -4149,7 +4251,7 @@ const messages: TranslationMap = {
'skills.channelIcon.telegram': 'Telegram',
'skills.channelIcon.web': 'ওয়েব',
'skills.channelIcon.yuanbao': 'Yuanbao',
'skills.composio.poweredBy': 'Composio দ্বারা চালিত',
'skills.composio.poweredBy': 'OAuth',
'skills.composio.staleStatusTitle': 'সংযোগগুলি পুরানো অবস্থা দেখাচ্ছে',
'skills.create.allowedTools': 'অনুমোদিত টুল',
'skills.create.allowedToolsHelp': 'হিসাবে SKILL.md ফ্রন্টম্যাটারে রেন্ডার করা হয়েছে',
@@ -4777,6 +4879,12 @@ const messages: TranslationMap = {
'autocomplete.maxChars': 'সর্বোচ্চ প্রসঙ্গ অক্ষর',
'autocomplete.overlayTtlMs': 'ওভারলে টাইমআউট (মিলিসেকেন্ড)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'অটোমেশন',
'activity.tabs.automationsDescription':
'পুনর্ব্যবহারযোগ্য, রানযোগ্য পদ্ধতি — একটি লক্ষ্য এবং সেখানে পৌঁছানোর ধাপগুলি।',
'activity.tabs.backgroundActivity': 'ব্যাকগ্রাউন্ড কার্যকলাপ',
'activity.tabs.alerts': 'সতর্কতা',
'intelligence.agents.title': 'এজেন্ট লাইব্রেরি',
'intelligence.agents.subtitle':
'চালানো যায় এমন বিশেষজ্ঞদের দেখুন এবং নির্দিষ্ট এজেন্টকে একটি কাজ পাঠান।',
+116 -5
View File
@@ -6,6 +6,11 @@ const messages: TranslationMap = {
'nav.home': 'Start',
'nav.human': 'Mensch',
'nav.chat': 'Chat',
'nav.assistant': 'Assistent',
'assistant.faceMode.on': 'Spricht mit Tiny',
'assistant.faceMode.off': 'Mit Tiny sprechen',
'assistant.faceMode.turnOn': 'Maskottchen anzeigen',
'assistant.faceMode.turnOff': 'Maskottchen ausblenden',
'nav.connections': 'Verbindungen',
'nav.memory': 'Intelligenz',
'nav.alerts': 'Benachrichtigungen',
@@ -15,6 +20,12 @@ const messages: TranslationMap = {
'nav.switchAgentProfile': 'Agentenprofil wechseln',
'nav.defaultAgentProfile': 'Standardagent',
'nav.noAgentProfiles': 'Keine Agentenprofile gefunden',
'nav.activity': 'Aktivität',
'nav.avatarMenu.account': 'Konto',
'nav.avatarMenu.billing': 'Abrechnung',
'nav.avatarMenu.rewards': 'Prämien',
'nav.avatarMenu.invites': 'Freund einladen',
'nav.avatarMenu.wallet': 'Wallet',
'common.cancel': 'Abbrechen',
'common.save': 'Speichern',
'common.confirm': 'Bestätigen',
@@ -59,6 +70,54 @@ const messages: TranslationMap = {
'common.comingSoon': 'Demnächst verfügbar',
'common.breadcrumb': 'Breadcrumb',
'settings.general': 'Allgemein',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Konto',
'settings.groups.assistant': 'Assistent',
'settings.groups.privacySecurity': 'Datenschutz & Sicherheit',
'settings.groups.notifications': 'Benachrichtigungen',
'settings.groups.about': 'Über',
'settings.assistant.personality': 'Persönlichkeit',
'settings.assistant.personalityDesc': 'Name, Beschreibung und SOUL.md-Persona',
'settings.assistant.voice': 'Stimme',
'settings.assistant.voiceDesc': 'Sprache-zu-Text und Text-zu-Sprache Einstellungen',
'settings.assistant.faceMascot': 'Gesicht / Maskottchen',
'settings.assistant.faceMascotDesc': 'Maskottchen-Farbe in der App auswählen',
'settings.assistant.backgroundActivity': 'Hintergrundaktivität',
'settings.assistant.backgroundActivityDesc':
'Steuern, wie aktiv Ihr Assistent im Hintergrund arbeitet',
'settings.assistant.screenAwareness': 'Bildschirmbewusstsein',
'settings.assistant.screenAwarenessDesc': 'Dem Assistenten das aktive Fenster zeigen',
'settings.assistant.desktopCompanion': 'Desktop-Begleiter',
'settings.assistant.desktopCompanionDesc':
'Immer aktiver Begleiter-Modus mit System-Tray-Shortcut',
'settings.assistant.permissions': 'Berechtigungen',
'settings.assistant.permissionsDesc': 'Wähle, was der Assistent tun darf und wo er arbeiten kann',
'settings.privacySecurity.privacy': 'Datenschutz',
'settings.privacySecurity.privacyDesc': 'Kontrollieren, welche Daten Ihren Computer verlassen',
'settings.privacySecurity.security': 'Sicherheit',
'settings.privacySecurity.securityDesc': 'Sitzungen und Anmeldeoptionen',
'settings.privacySecurity.approvalsHistory': 'Genehmigungen & Verlauf',
'settings.privacySecurity.approvalsHistoryDesc':
'Aktuelle Tool-Genehmigungsentscheidungen überprüfen',
'settings.notifications.menuTitle': 'Benachrichtigungen',
'settings.notifications.menuDesc': 'Benachrichtigungs-Posteingang und -Einstellungen',
'settings.devGroups.knowledgeMemory': 'Wissen & Gedächtnis',
'settings.devGroups.agentsAutonomy': 'Agenten & Autonomie',
'settings.devGroups.modelsInference': 'Modelle & Inferenz',
'settings.devGroups.automationIntegrations': 'Automatisierung & Integrationen',
'settings.devGroups.toolsCapabilities': 'Tools & Fähigkeiten',
'settings.devGroups.council': 'Rat',
'settings.analysisViews.title': 'Analyseansichten',
'settings.analysisViews.menuDesc':
'Speichergraph-Analyse — Diagramm, Zentralität, Kohäsion, Verknüpfungen, Aktualität, Zeitachse, Pfade und Namensräume',
'settings.buildInfo.title': 'Build-/Versionsinfo',
'settings.buildInfo.menuDesc': 'App-Build, Version und Details zur Core-Verbindung',
'settings.dataSync.title': 'Datensynchronisierung',
'settings.dataSync.menuDesc':
'Was dein Assistent synchronisiert — Quellen, Aktualität und Status',
'settings.dataSync.description':
'Verwalte, was in den Speicher deines Assistenten synchronisiert wird: jede verbundene Quelle mit ihrer letzten Synchronisierungszeit, wie viel synchronisiert ist und ob gerade synchronisiert wird.',
'settings.devGroups.diagnosticsLogs': 'Diagnose & Protokolle',
'settings.featuresAndAI': 'Funktionen und KI',
'settings.billingAndRewards': 'Abrechnung und Prämien',
'settings.support': 'Unterstützung',
@@ -87,6 +146,12 @@ const messages: TranslationMap = {
'settings.developerOptions': 'Fortgeschritten',
'settings.developerOptionsDesc':
'KI-Konfiguration, Nachrichtenkanäle, Tools, Diagnose und Debug-Panels',
'settings.developerDiagnostics': 'Entwickler & Diagnose',
'settings.developerDiagnosticsDesc':
'Erweiterte Entwickler-Tools, Diagnose, Speicher, Agents und Debug-Panels',
'settings.developerMode.title': 'Entwicklermodus',
'settings.developerMode.description': 'Erweiterte Entwickler- und Diagnose-Tools anzeigen',
'settings.developerMode.enabledByBuild': 'In Entwicklungs-Builds immer aktiviert',
'settings.clearAppData': 'App-Daten löschen',
'settings.clearAppDataDesc': 'Melde dich ab und lösche alle lokalen App-Daten dauerhaft',
'settings.logOut': 'Abmelden',
@@ -99,6 +164,14 @@ const messages: TranslationMap = {
'settings.alerts': 'Warnungen',
'settings.alertsDesc':
'Sieh dir aktuelle Benachrichtigungen und Aktivitäten in deinem Posteingang an',
'settings.account.profile': 'Profil',
'settings.account.profileDesc': 'Name, E-Mail und Avatar',
'settings.account.devices': 'Geräte',
'settings.account.devicesDesc': 'Mobile Geräte koppeln und verwalten',
'settings.account.teamMembers': 'Team & Mitglieder',
'settings.account.teamMembersDesc': 'Teamzugang und Mitgliederrollen verwalten',
'settings.account.dataMigration': 'Daten & Migration',
'settings.account.dataMigrationDesc': 'Erinnerungen aus einem anderen Assistenten importieren',
'settings.account.recoveryPhrase': 'Wiederherstellungssatz',
'settings.account.recoveryPhraseDesc':
'Sieh dir deinen Kontowiederherstellungssatz an und sichere ihn',
@@ -268,7 +341,7 @@ const messages: TranslationMap = {
'skills.connected': 'Verbunden',
'skills.available': 'Verfügbar',
'skills.addAccount': 'Konto hinzufügen',
'skills.channels': 'Kanäle',
'skills.channels': 'Messaging',
'skills.explorer.emptyCta': 'Von URL installieren',
'skills.explorer.emptyDescription':
'Installiere ein SKILL.md-Paket oder lege Hermes-artige Ordner unter ~/.openhuman/skills ab.',
@@ -296,9 +369,9 @@ const messages: TranslationMap = {
'skills.explorer.installed': 'Installiert',
'skills.explorer.install': 'Installieren',
'skills.explorer.installing': 'Wird installiert…',
'skills.integrations': 'Integrationen',
'skills.integrations': 'Apps',
'skills.integrationsSubtitle':
'Cloud-basierte OAuth-Verbindungen mit deinem Konto anmelden und Composio verwaltet die Tokens, damit Agenten in deinem Namen lesen und handeln können. Keine API-Schlüssel notwendig.',
'Cloud-basierte OAuth-Verbindungen mit deinem Konto anmelden und Tokens werden sicher verwaltet, damit Agenten in deinem Namen lesen und handeln können. Keine API-Schlüssel notwendig.',
'skills.composio.noApiKeyTitle': 'Kein Composio-API-Schlüssel konfiguriert',
'skills.composio.noApiKeyDescription':
'Im lokalen Modus wird dein eigener Composio-API-Schlüssel verwendet. Öffne Einstellungen → Erweitert → Composio, um einen Schlüssel hinzuzufügen, bevor du hier Integrationen verbindest.',
@@ -308,6 +381,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'Skills',
'skills.tabs.meetings': 'Google Meet-Besprechungen',
'skills.tabs.mcp': 'MCP Server',
'connections.tabs.apps': 'Apps',
'connections.tabs.messaging': 'Nachrichten',
'connections.tabs.tools': 'Werkzeuge',
'connections.tabs.explorer': 'Erkunden',
'connections.tabs.talents': 'Talente',
'memory.title': 'Erinnerung',
'memory.search': 'Erinnerungen suchen...',
'memory.noResults': 'Keine Erinnerungen gefunden',
@@ -719,6 +797,7 @@ const messages: TranslationMap = {
'team.failedChangeRole': 'Rolle konnte nicht geändert werden.',
'team.failedRemoveMember': 'Mitglied konnte nicht entfernt werden.',
'devOptions.title': 'Fortgeschritten',
'devOptions.titleDiagnostics': 'Entwickler & Diagnose',
'devOptions.diagnostics': 'Diagnose',
'devOptions.diagnosticsDesc': 'Systemzustand, Protokolle und Leistungsmetriken',
'devOptions.toolPolicyDiagnosticsDesc':
@@ -1868,7 +1947,7 @@ const messages: TranslationMap = {
'chat.safetyTimeout':
'Keine Antwort vom Agenten nach 2 Minuten. Versuche es erneut oder prüfe deine Verbindung.',
'chat.filter.general': 'Allgemein',
'chat.filter.subconscious': 'Unterbewusstsein',
'chat.filter.subconscious': 'Hintergrundaktivität',
'chat.filter.meetings': 'Meetings',
'chat.filter.tasks': 'Aufgaben',
'chat.selectThread': 'Wähle einen Thread aus',
@@ -3932,6 +4011,12 @@ const messages: TranslationMap = {
'settings.developerMenu.autonomy.title': 'Agent-Autonomie',
'settings.developerMenu.autonomy.desc':
'Aktionsraten-Limits und Sicherheitsschwellen für Werkzeuge',
'settings.developerMenu.autocomplete.title': 'Autovervollständigung',
'settings.developerMenu.autocomplete.desc':
'KI-Inline-Autovervollständigung Einstellungen und Debug-Panel',
'settings.developerMenu.voiceDebug.title': 'Sprache (Debug)',
'settings.developerMenu.voiceDebug.desc':
'Laufzeitstatus und Debug-Einstellungen für die Sprachdiktierung',
'settings.mcpServer.title': 'MCP-Server',
'settings.mcpServer.toolsSectionTitle': 'Verfügbare Werkzeuge',
'settings.mcpServer.toolsSectionDesc':
@@ -4035,6 +4120,26 @@ const messages: TranslationMap = {
'settings.agentAccess.approvalHistoryDesc':
'Vergangene Genehmigen-/Ablehnen-Entscheidungen des Agenten überprüfen.',
'settings.agentAccess.viewApprovalHistory': 'Genehmigungsverlauf anzeigen',
// ── Berechtigungen-Panel ──────────────────────────────────────────
'settings.permissions.title': 'Berechtigungen',
'settings.permissions.menuDesc': 'Wähle, was dein Assistent tun darf und wo er arbeiten kann.',
'settings.permissions.accessMode': 'Was darf der Assistent tun?',
'settings.permissions.accessModeDesc':
'Wähle, wie viel Freiheit der Assistent hat, wenn er Aktionen auf deinem Computer ausführt.',
'settings.permissions.preset.readonly.title': 'Schauen, nicht anfassen',
'settings.permissions.preset.readonly.desc':
'Der Assistent kann Dateien lesen und erkunden aber niemals schreiben, bearbeiten oder etwas ausführen, das den Zustand ändert.',
'settings.permissions.preset.supervised.title': 'Erst fragen',
'settings.permissions.preset.supervised.desc':
'Kann neue Dateien frei erstellen, fragt aber immer um Erlaubnis, bevor er bearbeitet, Befehle ausführt oder auf das Netzwerk zugreift.',
'settings.permissions.preset.full.title': 'Volle Kontrolle',
'settings.permissions.preset.full.desc':
'Läuft mit deinem vollen Kontozugriff. Destruktive Befehle, Netzwerkzugriff und Installationen erfordern weiterhin eine Genehmigung.',
'settings.permissions.folders': 'Wo darf er arbeiten?',
'settings.permissions.foldersDesc':
'Der Standardordner, den der Assistent liest und beschreibt. In den erweiterten Einstellungen kannst du weitere Ordner hinzufügen.',
'settings.sandbox.title': 'Sandbox-Ausführung',
'settings.sandbox.menuDesc':
'Sandbox-Backends für die Isolation von Agentenwerkzeugen konfigurieren.',
@@ -4259,7 +4364,7 @@ const messages: TranslationMap = {
'skills.channelIcon.telegram': 'Telegram',
'skills.channelIcon.web': 'Web',
'skills.channelIcon.yuanbao': 'Yuanbao',
'skills.composio.poweredBy': 'Unterstützt von Composio',
'skills.composio.poweredBy': 'OAuth',
'skills.composio.staleStatusTitle': 'Verbindungen zeigen den veralteten Status an',
'skills.create.allowedTools': 'Erlaubte Werkzeuge',
'skills.create.allowedToolsHelp': 'Im SKILL.md-Frontmatter gerendert als',
@@ -4904,6 +5009,12 @@ const messages: TranslationMap = {
'autocomplete.maxChars': 'Maximale Kontextzeichen',
'autocomplete.overlayTtlMs': 'Overlay-Timeout (ms)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'Automatisierungen',
'activity.tabs.automationsDescription':
'Wiederverwendbare, ausführbare Abläufe — ein Ziel und die Schritte dorthin.',
'activity.tabs.backgroundActivity': 'Hintergrundaktivität',
'activity.tabs.alerts': 'Benachrichtigungen',
'intelligence.agents.title': 'Agentenbibliothek',
'intelligence.agents.subtitle':
'Prüfe ausführbare Spezialisten und sende eine Aufgabe an einen benannten Agenten.',
+130 -6
View File
@@ -5,6 +5,13 @@ const en: TranslationMap = {
'nav.home': 'Home',
'nav.human': 'Human',
'nav.chat': 'Chat',
'nav.assistant': 'Assistant',
// Assistant surface — face mode toggle (Phase 6)
'assistant.faceMode.on': 'Talking to Tiny',
'assistant.faceMode.off': 'Talk to Tiny',
'assistant.faceMode.turnOn': 'Show mascot face',
'assistant.faceMode.turnOff': 'Hide mascot face',
'nav.connections': 'Connections',
'nav.memory': 'Intelligence',
'nav.alerts': 'Alerts',
@@ -14,6 +21,12 @@ const en: TranslationMap = {
'nav.switchAgentProfile': 'Switch agent profile',
'nav.defaultAgentProfile': 'Default agent',
'nav.noAgentProfiles': 'No agent profiles found',
'nav.activity': 'Activity',
'nav.avatarMenu.account': 'Account',
'nav.avatarMenu.billing': 'Billing',
'nav.avatarMenu.rewards': 'Rewards',
'nav.avatarMenu.invites': 'Invite a friend',
'nav.avatarMenu.wallet': 'Wallet',
// Common
'common.cancel': 'Cancel',
@@ -69,6 +82,60 @@ const en: TranslationMap = {
'settings.dangerZone': 'Danger Zone',
'settings.account': 'Account',
'settings.accountDesc': 'Recovery phrase, team, connections, and privacy',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Account',
'settings.groups.assistant': 'Assistant',
'settings.groups.privacySecurity': 'Privacy & Security',
'settings.groups.notifications': 'Notifications',
'settings.groups.about': 'About',
// Settings — assistant group items
'settings.assistant.personality': 'Personality',
'settings.assistant.personalityDesc': 'Name, description, and SOUL.md persona',
'settings.assistant.voice': 'Voice',
'settings.assistant.voiceDesc': 'Speech-to-text and text-to-speech settings',
'settings.assistant.faceMascot': 'Face / Mascot',
'settings.assistant.faceMascotDesc': 'Pick the mascot color used across the app',
'settings.assistant.backgroundActivity': 'Background activity',
'settings.assistant.backgroundActivityDesc':
'Control how actively your assistant works in the background',
'settings.assistant.screenAwareness': 'Screen awareness',
'settings.assistant.screenAwarenessDesc': 'Let the assistant see your active window',
'settings.assistant.desktopCompanion': 'Desktop companion',
'settings.assistant.desktopCompanionDesc': 'Always-on companion mode with a system-tray shortcut',
'settings.assistant.permissions': 'Permissions',
'settings.assistant.permissionsDesc': 'Choose what the assistant can do and where it can work',
// Settings — privacy & security group items
'settings.privacySecurity.privacy': 'Privacy',
'settings.privacySecurity.privacyDesc': 'Control what data leaves your computer',
'settings.privacySecurity.security': 'Security',
'settings.privacySecurity.securityDesc': 'Sessions and sign-in options',
'settings.privacySecurity.approvalsHistory': 'Approvals & history',
'settings.privacySecurity.approvalsHistoryDesc': 'Review recent tool-approval decisions',
// Settings — notifications group items
'settings.notifications.menuTitle': 'Notifications',
'settings.notifications.menuDesc': 'Alerts inbox and notification preferences',
// Developer & Diagnostics — 7 sub-section group labels
'settings.devGroups.knowledgeMemory': 'Knowledge & Memory',
'settings.devGroups.agentsAutonomy': 'Agents & Autonomy',
'settings.devGroups.modelsInference': 'Models & Inference',
'settings.devGroups.automationIntegrations': 'Automation & Integrations',
'settings.devGroups.toolsCapabilities': 'Tools & Capabilities',
'settings.devGroups.council': 'Council',
'settings.analysisViews.title': 'Analysis views',
'settings.analysisViews.menuDesc':
'Memory graph analysis — diagram, centrality, cohesion, associations, freshness, timeline, paths, and namespaces',
'settings.buildInfo.title': 'Build / version info',
'settings.buildInfo.menuDesc': 'App build, version, and core connection details',
'settings.dataSync.title': 'Data Sync',
'settings.dataSync.menuDesc': 'What your assistant syncs — sources, freshness, and status',
'settings.dataSync.description':
"Manage what gets synced into your assistant's memory: every connected source with its last-synced time, how much is synced, and whether it's syncing right now.",
'settings.devGroups.diagnosticsLogs': 'Diagnostics & Logs',
'settings.notifications': 'Notifications',
'settings.notificationsDesc': 'Do Not Disturb and per-account notification controls',
'settings.notifications.tabs.preferences': 'Preferences',
@@ -90,6 +157,14 @@ const en: TranslationMap = {
'settings.developerOptions': 'Advanced',
'settings.developerOptionsDesc':
'AI configuration, messaging channels, tools, diagnostics, and debug panels',
// Developer & Diagnostics (renamed from "Developer Options")
'settings.developerDiagnostics': 'Developer & Diagnostics',
'settings.developerDiagnosticsDesc':
'Advanced developer tools, diagnostics, memory, agents, and debug panels',
// Runtime Developer Mode toggle (in About panel)
'settings.developerMode.title': 'Developer mode',
'settings.developerMode.description': 'Show advanced developer & diagnostic tools',
'settings.developerMode.enabledByBuild': 'Always on in development builds',
'settings.clearAppData': 'Clear App Data',
'settings.clearAppDataDesc': 'Sign out and permanently clear all local app data',
'settings.logOut': 'Log out',
@@ -102,6 +177,16 @@ const en: TranslationMap = {
'settings.alerts': 'Alerts',
'settings.alertsDesc': 'View recent alerts and activity in your inbox',
// Settings: Account — IA revamp group items (SettingsHome.tsx)
'settings.account.profile': 'Profile',
'settings.account.profileDesc': 'Name, email, and avatar',
'settings.account.devices': 'Devices',
'settings.account.devicesDesc': 'Pair and manage mobile devices',
'settings.account.teamMembers': 'Team & members',
'settings.account.teamMembersDesc': 'Manage team access and member roles',
'settings.account.dataMigration': 'Data & migration',
'settings.account.dataMigrationDesc': 'Import memory from another assistant',
// Settings: Account
'settings.account.recoveryPhrase': 'Recovery Phrase',
'settings.account.recoveryPhraseDesc': 'View and back up your account recovery phrase',
@@ -284,7 +369,7 @@ const en: TranslationMap = {
'skills.connected': 'Connected',
'skills.available': 'Available',
'skills.addAccount': 'Add Account',
'skills.channels': 'Channels',
'skills.channels': 'Messaging',
'skills.explorer.emptyCta': 'Install from URL',
'skills.explorer.emptyDescription':
'Install a SKILL.md package or place Hermes-style folders under ~/.openhuman/skills.',
@@ -312,9 +397,9 @@ const en: TranslationMap = {
'skills.explorer.installed': 'Installed',
'skills.explorer.install': 'Install',
'skills.explorer.installing': 'Installing…',
'skills.integrations': 'Composio Integrations',
'skills.integrations': 'Apps',
'skills.integrationsSubtitle':
'Cloud-based OAuth connections — sign in with your account and Composio brokers the tokens so agents can read and act on your behalf. No API keys to manage.',
'Cloud-based OAuth connections — sign in with your account and tokens are brokered securely so agents can read and act on your behalf. No API keys to manage.',
'skills.composio.noApiKeyTitle': 'No Composio API Key Configured',
'skills.composio.noApiKeyDescription':
'Local mode uses your own Composio API key. Open Settings → Advanced → Composio to add one before connecting integrations here.',
@@ -324,6 +409,12 @@ const en: TranslationMap = {
'skills.tabs.explorer': 'Skills',
'skills.tabs.meetings': 'Google Meet',
'skills.tabs.mcp': 'MCP Servers',
// Connections page tabs (Phase 2 rename)
'connections.tabs.apps': 'Apps',
'connections.tabs.messaging': 'Messaging',
'connections.tabs.tools': 'Tools',
'connections.tabs.explorer': 'Explorer',
'connections.tabs.talents': 'Talents',
// Intelligence / Memory
'memory.title': 'Memory',
'memory.search': 'Search memories...',
@@ -353,6 +444,14 @@ const en: TranslationMap = {
'memory.tab.cohesion': 'Cohesion',
'memory.tab.settings': 'Settings',
'memory.tab.council': 'Council',
// Activity surface — Phase 3 renamed tabs
'activity.tabs.automations': 'Automations',
'activity.tabs.automationsDescription':
'Reusable, runnable procedures — a goal plus the steps to reach it. Create one, install from a URL, or open a workflow to run it.',
'activity.tabs.backgroundActivity': 'Background activity',
'activity.tabs.alerts': 'Alerts',
'intelligence.agents.title': 'Agents Library',
'intelligence.agents.subtitle':
'Inspect runnable specialists and send one task to a named agent.',
@@ -1014,8 +1113,9 @@ const en: TranslationMap = {
'team.failedChangeRole': 'Failed to change role',
'team.failedRemoveMember': 'Failed to remove member',
// Developer Options
// Developer Options / Developer & Diagnostics
'devOptions.title': 'Advanced',
'devOptions.titleDiagnostics': 'Developer & Diagnostics',
'devOptions.diagnostics': 'Diagnostics',
'devOptions.diagnosticsDesc': 'System health, logs, and performance metrics',
'devOptions.toolPolicyDiagnosticsDesc':
@@ -2196,7 +2296,7 @@ const en: TranslationMap = {
'chat.safetyTimeout':
'No response from the agent after 2 minutes. Try again or check your connection.',
'chat.filter.general': 'General',
'chat.filter.subconscious': 'Subconscious',
'chat.filter.subconscious': 'Background activity',
'chat.filter.meetings': 'Meetings',
'chat.filter.tasks': 'Tasks',
'chat.selectThread': 'Select a thread',
@@ -4351,6 +4451,11 @@ const en: TranslationMap = {
'settings.developerMenu.mcpServer.desc': 'Configure external MCP clients to connect to OpenHuman',
'settings.developerMenu.autonomy.title': 'Agent autonomy',
'settings.developerMenu.autonomy.desc': 'Tool action rate limits and safety thresholds',
// Tools & Capabilities group — autocomplete and voice debug (doc § Tools & Capabilities)
'settings.developerMenu.autocomplete.title': 'Autocomplete',
'settings.developerMenu.autocomplete.desc': 'AI inline autocomplete settings and debug panel',
'settings.developerMenu.voiceDebug.title': 'Voice (debug)',
'settings.developerMenu.voiceDebug.desc': 'Voice dictation runtime status and debug settings',
'settings.mcpServer.title': 'MCP Server',
'settings.mcpServer.toolsSectionTitle': 'Available Tools',
'settings.mcpServer.toolsSectionDesc':
@@ -4453,6 +4558,25 @@ const en: TranslationMap = {
'Review past Approve / Deny decisions the agent requested.',
'settings.agentAccess.viewApprovalHistory': 'View approval history',
// ── Permissions panel (layman split from Agent Access, Deferred-3) ────────
'settings.permissions.title': 'Permissions',
'settings.permissions.menuDesc': 'Choose what your assistant can do and where it can work.',
'settings.permissions.accessMode': 'What can the assistant do?',
'settings.permissions.accessModeDesc':
'Choose how much freedom the assistant has when it takes actions on your computer.',
'settings.permissions.preset.readonly.title': "Look, don't touch",
'settings.permissions.preset.readonly.desc':
'The assistant can read files and explore — but never write, edit, or run anything that changes state.',
'settings.permissions.preset.supervised.title': 'Ask me first',
'settings.permissions.preset.supervised.desc':
'Can create new files freely, but always asks your approval before editing, running commands, or accessing the network.',
'settings.permissions.preset.full.title': 'Full control',
'settings.permissions.preset.full.desc':
'Runs with your full account access. Destructive commands, network access, and installs still ask for approval.',
'settings.permissions.folders': 'Where can it work?',
'settings.permissions.foldersDesc':
'The default folder the assistant reads and writes. You can add more folders in Advanced settings.',
// ── Sandbox execution backend ─────────────────────────────────────
'settings.sandbox.title': 'Sandbox execution',
'settings.sandbox.menuDesc': 'Configure sandbox backends for agent tool isolation.',
@@ -4672,7 +4796,7 @@ const en: TranslationMap = {
'skills.channelIcon.telegram': 'Telegram',
'skills.channelIcon.web': 'Web',
'skills.channelIcon.yuanbao': 'Yuanbao',
'skills.composio.poweredBy': 'Powered by Composio',
'skills.composio.poweredBy': 'OAuth',
'skills.composio.staleStatusTitle': 'Connections are showing stale status',
'skills.create.allowedTools': 'Allowed tools',
'skills.create.allowedToolsHelp': 'Rendered into the SKILL.md frontmatter as',
+116 -5
View File
@@ -6,6 +6,11 @@ const messages: TranslationMap = {
'nav.home': 'Inicio',
'nav.human': 'Humano',
'nav.chat': 'Charla',
'nav.assistant': 'Asistente',
'assistant.faceMode.on': 'Hablando con Tiny',
'assistant.faceMode.off': 'Habla con Tiny',
'assistant.faceMode.turnOn': 'Mostrar mascota',
'assistant.faceMode.turnOff': 'Ocultar mascota',
'nav.connections': 'Conexiones',
'nav.memory': 'Inteligencia',
'nav.alerts': 'Alertas',
@@ -15,6 +20,12 @@ const messages: TranslationMap = {
'nav.switchAgentProfile': 'Cambiar perfil de agente',
'nav.defaultAgentProfile': 'Agente predeterminado',
'nav.noAgentProfiles': 'No se encontraron perfiles de agente',
'nav.activity': 'Actividad',
'nav.avatarMenu.account': 'Cuenta',
'nav.avatarMenu.billing': 'Facturación',
'nav.avatarMenu.rewards': 'Recompensas',
'nav.avatarMenu.invites': 'Invitar a un amigo',
'nav.avatarMenu.wallet': 'Cartera',
'common.cancel': 'Cancelar',
'common.save': 'Guardar',
'common.confirm': 'Confirmar',
@@ -59,6 +70,53 @@ const messages: TranslationMap = {
'common.comingSoon': 'Próximamente',
'common.breadcrumb': 'Miga de pan',
'settings.general': 'generales',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Cuenta',
'settings.groups.assistant': 'Asistente',
'settings.groups.privacySecurity': 'Privacidad y seguridad',
'settings.groups.notifications': 'Notificaciones',
'settings.groups.about': 'Acerca de',
'settings.assistant.personality': 'Personalidad',
'settings.assistant.personalityDesc': 'Nombre, descripción y persona SOUL.md',
'settings.assistant.voice': 'Voz',
'settings.assistant.voiceDesc': 'Configuración de voz a texto y texto a voz',
'settings.assistant.faceMascot': 'Cara / Mascota',
'settings.assistant.faceMascotDesc': 'Elige el color de la mascota en la aplicación',
'settings.assistant.backgroundActivity': 'Actividad en segundo plano',
'settings.assistant.backgroundActivityDesc':
'Controla qué tan activo trabaja tu asistente en segundo plano',
'settings.assistant.screenAwareness': 'Conciencia de pantalla',
'settings.assistant.screenAwarenessDesc': 'Permite al asistente ver tu ventana activa',
'settings.assistant.desktopCompanion': 'Compañero de escritorio',
'settings.assistant.desktopCompanionDesc':
'Modo compañero siempre activo con acceso directo en la bandeja del sistema',
'settings.assistant.permissions': 'Permisos',
'settings.assistant.permissionsDesc': 'Elige qué puede hacer el asistente y dónde puede trabajar',
'settings.privacySecurity.privacy': 'Privacidad',
'settings.privacySecurity.privacyDesc': 'Controla qué datos salen de tu ordenador',
'settings.privacySecurity.security': 'Seguridad',
'settings.privacySecurity.securityDesc': 'Sesiones y opciones de inicio de sesión',
'settings.privacySecurity.approvalsHistory': 'Aprobaciones e historial',
'settings.privacySecurity.approvalsHistoryDesc':
'Revisa las decisiones de aprobación de herramientas recientes',
'settings.notifications.menuTitle': 'Notificaciones',
'settings.notifications.menuDesc': 'Bandeja de alertas y preferencias de notificaciones',
'settings.devGroups.knowledgeMemory': 'Conocimiento y memoria',
'settings.devGroups.agentsAutonomy': 'Agentes y autonomía',
'settings.devGroups.modelsInference': 'Modelos e inferencia',
'settings.devGroups.automationIntegrations': 'Automatización e integraciones',
'settings.devGroups.toolsCapabilities': 'Herramientas y capacidades',
'settings.devGroups.council': 'Consejo',
'settings.analysisViews.title': 'Vistas de análisis',
'settings.analysisViews.menuDesc':
'Análisis del grafo de memoria: diagrama, centralidad, cohesión, asociaciones, frescura, línea de tiempo, rutas y espacios de nombres',
'settings.buildInfo.title': 'Información de compilación/versión',
'settings.buildInfo.menuDesc': 'Compilación de la app, versión y detalles de conexión del núcleo',
'settings.dataSync.title': 'Sincronización de datos',
'settings.dataSync.menuDesc': 'Lo que sincroniza tu asistente: fuentes, actualidad y estado',
'settings.dataSync.description':
'Gestiona qué se sincroniza en la memoria de tu asistente: cada fuente conectada con su última sincronización, cuánto se ha sincronizado y si se está sincronizando ahora.',
'settings.devGroups.diagnosticsLogs': 'Diagnósticos y registros',
'settings.featuresAndAI': 'Funciones e IA',
'settings.billingAndRewards': 'Facturación y recompensas',
'settings.support': 'Soporte',
@@ -88,6 +146,13 @@ const messages: TranslationMap = {
'settings.developerOptions': 'Avanzado',
'settings.developerOptionsDesc':
'Configuración de IA, canales de mensajería, herramientas, diagnósticos y paneles de depuración',
'settings.developerDiagnostics': 'Desarrollador y Diagnóstico',
'settings.developerDiagnosticsDesc':
'Herramientas avanzadas de desarrollador, diagnóstico, memoria, agentes y paneles de depuración',
'settings.developerMode.title': 'Modo desarrollador',
'settings.developerMode.description':
'Mostrar herramientas avanzadas de desarrollador y diagnóstico',
'settings.developerMode.enabledByBuild': 'Siempre activo en compilaciones de desarrollo',
'settings.clearAppData': 'Borrar datos de la app',
'settings.clearAppDataDesc':
'Cerrar sesión y eliminar permanentemente todos los datos locales de la app',
@@ -100,6 +165,14 @@ const messages: TranslationMap = {
'settings.languageDesc': 'Idioma de visualización de la interfaz de la app',
'settings.alerts': 'Alertas',
'settings.alertsDesc': 'Ver alertas recientes y actividad en tu bandeja',
'settings.account.profile': 'Perfil',
'settings.account.profileDesc': 'Nombre, correo electrónico y avatar',
'settings.account.devices': 'Dispositivos',
'settings.account.devicesDesc': 'Vincular y gestionar dispositivos móviles',
'settings.account.teamMembers': 'Equipo y miembros',
'settings.account.teamMembersDesc': 'Gestionar acceso al equipo y roles de miembros',
'settings.account.dataMigration': 'Datos y migración',
'settings.account.dataMigrationDesc': 'Importar memoria desde otro asistente',
'settings.account.recoveryPhrase': 'Frase de recuperación',
'settings.account.recoveryPhraseDesc': 'Ver y respaldar tu frase de recuperación de cuenta',
'settings.account.team': 'Equipo',
@@ -269,7 +342,7 @@ const messages: TranslationMap = {
'skills.connected': 'Conectado',
'skills.available': 'Disponible',
'skills.addAccount': 'Agregar cuenta',
'skills.channels': 'Canales',
'skills.channels': 'Mensajería',
'skills.explorer.emptyCta': 'Instalar desde URL',
'skills.explorer.emptyDescription':
'Instala un paquete SKILL.md o coloca carpetas estilo Hermes en ~/.openhuman/skills.',
@@ -297,9 +370,9 @@ const messages: TranslationMap = {
'skills.explorer.installed': 'Instalada',
'skills.explorer.install': 'Instalar',
'skills.explorer.installing': 'Instalando…',
'skills.integrations': 'Integraciones',
'skills.integrations': 'Aplicaciones',
'skills.integrationsSubtitle':
'Conexiones OAuth en la nube: inicia sesión con tu cuenta y Composio gestiona los tokens para que los agentes puedan leer y actuar en tu nombre. Sin claves de API que administrar.',
'Conexiones OAuth en la nube: inicia sesión con tu cuenta y los tokens se gestionan de forma segura para que los agentes puedan leer y actuar en tu nombre. Sin claves de API que administrar.',
'skills.composio.noApiKeyTitle': 'No hay una API key de Composio configurada',
'skills.composio.noApiKeyDescription':
'El modo local usa tu propia API key de Composio. Abre Ajustes → Avanzado → Composio para añadir una antes de conectar integraciones aquí.',
@@ -309,6 +382,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'Skills',
'skills.tabs.meetings': 'Reuniones de Google Meet',
'skills.tabs.mcp': 'MCP Servidores',
'connections.tabs.apps': 'Aplicaciones',
'connections.tabs.messaging': 'Mensajería',
'connections.tabs.tools': 'Herramientas',
'connections.tabs.explorer': 'Explorar',
'connections.tabs.talents': 'Talentos',
'memory.title': 'Memoria',
'memory.search': 'Buscar recuerdos...',
'memory.noResults': 'No se encontraron recuerdos',
@@ -717,6 +795,7 @@ const messages: TranslationMap = {
'team.failedChangeRole': 'No se pudo cambiar el rol',
'team.failedRemoveMember': 'No se pudo eliminar el miembro',
'devOptions.title': 'Avanzado',
'devOptions.titleDiagnostics': 'Desarrollador y Diagnóstico',
'devOptions.diagnostics': 'Diagnósticos',
'devOptions.diagnosticsDesc': 'Estado del sistema, registros y métricas de rendimiento',
'devOptions.toolPolicyDiagnosticsDesc':
@@ -1860,7 +1939,7 @@ const messages: TranslationMap = {
'chat.safetyTimeout':
'Sin respuesta del agente después de 2 minutos. Intenta de nuevo o verifica tu conexión.',
'chat.filter.general': 'General',
'chat.filter.subconscious': 'Subconsciente',
'chat.filter.subconscious': 'Actividad en segundo plano',
'chat.filter.meetings': 'Reuniones',
'chat.filter.tasks': 'Tareas',
'chat.selectThread': 'Selecciona un hilo',
@@ -3905,6 +3984,12 @@ const messages: TranslationMap = {
'settings.developerMenu.autonomy.title': 'Autonomía del agente',
'settings.developerMenu.autonomy.desc':
'Límites de frecuencia de acciones de herramientas y umbrales de seguridad',
'settings.developerMenu.autocomplete.title': 'Autocompletar',
'settings.developerMenu.autocomplete.desc':
'Configuración de autocompletado en línea con IA y panel de depuración',
'settings.developerMenu.voiceDebug.title': 'Voz (depuración)',
'settings.developerMenu.voiceDebug.desc':
'Estado del tiempo de ejecución del dictado de voz y configuración de depuración',
'settings.mcpServer.title': 'MCP Servidor',
'settings.mcpServer.toolsSectionTitle': 'Herramientas disponibles',
'settings.mcpServer.toolsSectionDesc':
@@ -4007,6 +4092,26 @@ const messages: TranslationMap = {
'settings.agentAccess.approvalHistoryDesc':
'Revisa las decisiones anteriores de Aprobar / Denegar solicitadas por el agente.',
'settings.agentAccess.viewApprovalHistory': 'Ver historial de aprobaciones',
// ── Panel de Permisos ─────────────────────────────────────────────
'settings.permissions.title': 'Permisos',
'settings.permissions.menuDesc': 'Elige qué puede hacer tu asistente y dónde puede trabajar.',
'settings.permissions.accessMode': '¿Qué puede hacer el asistente?',
'settings.permissions.accessModeDesc':
'Elige cuánta libertad tiene el asistente al realizar acciones en tu ordenador.',
'settings.permissions.preset.readonly.title': 'Solo mirar',
'settings.permissions.preset.readonly.desc':
'El asistente puede leer archivos y explorar, pero nunca escribir, editar ni ejecutar nada que cambie el estado.',
'settings.permissions.preset.supervised.title': 'Preguntar primero',
'settings.permissions.preset.supervised.desc':
'Puede crear archivos nuevos libremente, pero siempre pedirá tu aprobación antes de editar, ejecutar comandos o acceder a la red.',
'settings.permissions.preset.full.title': 'Control total',
'settings.permissions.preset.full.desc':
'Opera con tu acceso de cuenta completo. Los comandos destructivos, el acceso a la red y las instalaciones siguen pidiendo aprobación.',
'settings.permissions.folders': '¿Dónde puede trabajar?',
'settings.permissions.foldersDesc':
'La carpeta predeterminada que el asistente lee y escribe. Puedes añadir más carpetas en los ajustes avanzados.',
'settings.sandbox.title': 'Ejecución en sandbox',
'settings.sandbox.menuDesc':
'Configurar backends de sandbox para el aislamiento de herramientas del agente.',
@@ -4231,7 +4336,7 @@ const messages: TranslationMap = {
'skills.channelIcon.telegram': 'Telegram',
'skills.channelIcon.web': 'Web',
'skills.channelIcon.yuanbao': 'Yuanbao',
'skills.composio.poweredBy': 'Desarrollado por Composio',
'skills.composio.poweredBy': 'OAuth',
'skills.composio.staleStatusTitle': 'Las conexiones muestran un estado obsoleto',
'skills.create.allowedTools': 'Herramientas permitidas',
'skills.create.allowedToolsHelp': 'Representado en el frontmatter de SKILL.md como',
@@ -4876,6 +4981,12 @@ const messages: TranslationMap = {
'autocomplete.maxChars': 'Máximo de caracteres de contexto',
'autocomplete.overlayTtlMs': 'Tiempo de espera de superposición (ms)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'Automatizaciones',
'activity.tabs.automationsDescription':
'Procedimientos reutilizables y ejecutables — un objetivo y los pasos para alcanzarlo.',
'activity.tabs.backgroundActivity': 'Actividad en segundo plano',
'activity.tabs.alerts': 'Alertas',
'intelligence.agents.title': 'Biblioteca de agentes',
'intelligence.agents.subtitle':
'Revisa especialistas ejecutables y envía una tarea a un agente concreto.',
+118 -5
View File
@@ -6,6 +6,11 @@ const messages: TranslationMap = {
'nav.home': 'Accueil',
'nav.human': 'Humain',
'nav.chat': 'Chat',
'nav.assistant': 'Assistant',
'assistant.faceMode.on': 'Parle avec Tiny',
'assistant.faceMode.off': 'Parler à Tiny',
'assistant.faceMode.turnOn': 'Afficher la mascotte',
'assistant.faceMode.turnOff': 'Masquer la mascotte',
'nav.connections': 'Connexions',
'nav.memory': 'Intelligence',
'nav.alerts': 'Alertes',
@@ -15,6 +20,12 @@ const messages: TranslationMap = {
'nav.switchAgentProfile': "Changer de profil d'agent",
'nav.defaultAgentProfile': 'Agent par défaut',
'nav.noAgentProfiles': "Aucun profil d'agent trouvé",
'nav.activity': 'Activité',
'nav.avatarMenu.account': 'Compte',
'nav.avatarMenu.billing': 'Facturation',
'nav.avatarMenu.rewards': 'Récompenses',
'nav.avatarMenu.invites': 'Inviter un ami',
'nav.avatarMenu.wallet': 'Portefeuille',
'common.cancel': 'Annuler',
'common.save': 'Enregistrer',
'common.confirm': 'Confirmer',
@@ -59,6 +70,54 @@ const messages: TranslationMap = {
'common.comingSoon': 'Bientôt disponible',
'common.breadcrumb': "Fil d'Ariane",
'settings.general': 'Général',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Compte',
'settings.groups.assistant': 'Assistant',
'settings.groups.privacySecurity': 'Confidentialité et sécurité',
'settings.groups.notifications': 'Notifications',
'settings.groups.about': 'À propos',
'settings.assistant.personality': 'Personnalité',
'settings.assistant.personalityDesc': 'Nom, description et persona SOUL.md',
'settings.assistant.voice': 'Voix',
'settings.assistant.voiceDesc': 'Paramètres de synthèse vocale et de reconnaissance vocale',
'settings.assistant.faceMascot': 'Visage / Mascotte',
'settings.assistant.faceMascotDesc': "Choisir la couleur de la mascotte dans l'application",
'settings.assistant.backgroundActivity': 'Activité en arrière-plan',
'settings.assistant.backgroundActivityDesc':
"Contrôler l'activité en arrière-plan de votre assistant",
'settings.assistant.screenAwareness': "Conscience de l'écran",
'settings.assistant.screenAwarenessDesc': "Permettre à l'assistant de voir votre fenêtre active",
'settings.assistant.desktopCompanion': 'Compagnon de bureau',
'settings.assistant.desktopCompanionDesc':
'Mode compagnon toujours actif avec raccourci dans la barre système',
'settings.assistant.permissions': 'Autorisations',
'settings.assistant.permissionsDesc':
"Choisissez ce que l'assistant peut faire et où il peut travailler",
'settings.privacySecurity.privacy': 'Confidentialité',
'settings.privacySecurity.privacyDesc': 'Contrôler quelles données quittent votre ordinateur',
'settings.privacySecurity.security': 'Sécurité',
'settings.privacySecurity.securityDesc': 'Sessions et options de connexion',
'settings.privacySecurity.approvalsHistory': 'Approbations et historique',
'settings.privacySecurity.approvalsHistoryDesc':
"Examiner les décisions d'approbation récentes des outils",
'settings.notifications.menuTitle': 'Notifications',
'settings.notifications.menuDesc': "Boîte de réception d'alertes et préférences de notifications",
'settings.devGroups.knowledgeMemory': 'Connaissance et mémoire',
'settings.devGroups.agentsAutonomy': 'Agents et autonomie',
'settings.devGroups.modelsInference': 'Modèles et inférence',
'settings.devGroups.automationIntegrations': 'Automatisation et intégrations',
'settings.devGroups.toolsCapabilities': 'Outils et capacités',
'settings.devGroups.council': 'Conseil',
'settings.analysisViews.title': 'Vues danalyse',
'settings.analysisViews.menuDesc':
'Analyse du graphe mémoire — diagramme, centralité, cohésion, associations, fraîcheur, chronologie, chemins et espaces de noms',
'settings.buildInfo.title': 'Infos de build/version',
'settings.buildInfo.menuDesc': 'Build de lapplication, version et détails de connexion du cœur',
'settings.dataSync.title': 'Synchronisation des données',
'settings.dataSync.menuDesc': 'Ce que votre assistant synchronise — sources, fraîcheur et état',
'settings.dataSync.description':
'Gérez ce qui est synchronisé dans la mémoire de votre assistant : chaque source connectée avec sa dernière synchronisation, la quantité synchronisée et si une synchronisation est en cours.',
'settings.devGroups.diagnosticsLogs': 'Diagnostics et journaux',
'settings.featuresAndAI': 'Fonctionnalités & IA',
'settings.billingAndRewards': 'Facturation & Récompenses',
'settings.support': 'Assistance',
@@ -88,6 +147,13 @@ const messages: TranslationMap = {
'settings.developerOptions': 'Avancé',
'settings.developerOptionsDesc':
'Configuration IA, canaux de messagerie, outils, diagnostics et panneaux de débogage',
'settings.developerDiagnostics': 'Développeur et Diagnostics',
'settings.developerDiagnosticsDesc':
'Outils avancés de développeur, diagnostics, mémoire, agents et panneaux de débogage',
'settings.developerMode.title': 'Mode développeur',
'settings.developerMode.description':
'Afficher les outils avancés de développeur et de diagnostic',
'settings.developerMode.enabledByBuild': 'Toujours activé dans les builds de développement',
'settings.clearAppData': "Effacer les données de l'app",
'settings.clearAppDataDesc':
'Se déconnecter et supprimer définitivement toutes les données locales',
@@ -100,6 +166,14 @@ const messages: TranslationMap = {
'settings.languageDesc': "Langue d'affichage de l'interface",
'settings.alerts': 'Alertes',
'settings.alertsDesc': "Voir les alertes récentes et l'activité dans ta boîte de réception",
'settings.account.profile': 'Profil',
'settings.account.profileDesc': 'Nom, e-mail et avatar',
'settings.account.devices': 'Appareils',
'settings.account.devicesDesc': 'Associer et gérer les appareils mobiles',
'settings.account.teamMembers': 'Équipe & membres',
'settings.account.teamMembersDesc': "Gérer l'accès à l'équipe et les rôles des membres",
'settings.account.dataMigration': 'Données & migration',
'settings.account.dataMigrationDesc': 'Importer la mémoire depuis un autre assistant',
'settings.account.recoveryPhrase': 'Phrase de récupération',
'settings.account.recoveryPhraseDesc': 'Voir et sauvegarder ta phrase de récupération',
'settings.account.team': 'Équipe',
@@ -267,7 +341,7 @@ const messages: TranslationMap = {
'skills.connected': 'Connecté',
'skills.available': 'Disponible',
'skills.addAccount': 'Ajouter un compte',
'skills.channels': 'Canaux',
'skills.channels': 'Messagerie',
'skills.explorer.emptyCta': 'Installer depuis une URL',
'skills.explorer.emptyDescription':
'Installez un paquet SKILL.md ou placez des dossiers de style Hermes dans ~/.openhuman/skills.',
@@ -295,9 +369,9 @@ const messages: TranslationMap = {
'skills.explorer.installed': 'Installée',
'skills.explorer.install': 'Installer',
'skills.explorer.installing': 'Installation…',
'skills.integrations': 'Intégrations',
'skills.integrations': 'Applications',
'skills.integrationsSubtitle':
'Connexions OAuth cloud — connectez-vous avec votre compte et Composio gère les jetons pour que les agents puissent lire et agir en votre nom. Aucune clé API à gérer.',
'Connexions OAuth cloud — connectez-vous avec votre compte et les jetons sont gérés en toute sécurité pour que les agents puissent lire et agir en votre nom. Aucune clé API à gérer.',
'skills.composio.noApiKeyTitle': 'Aucune clé API Composio configurée',
'skills.composio.noApiKeyDescription':
'Le mode local utilise votre propre clé API Composio. Ouvrez Paramètres → Avancé → Composio pour en ajouter une avant de connecter des intégrations ici.',
@@ -307,6 +381,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'Skills',
'skills.tabs.meetings': 'Réunions Google Meet',
'skills.tabs.mcp': 'MCP Serveurs',
'connections.tabs.apps': 'Applications',
'connections.tabs.messaging': 'Messagerie',
'connections.tabs.tools': 'Outils',
'connections.tabs.explorer': 'Explorateur',
'connections.tabs.talents': 'Talents',
'memory.title': 'Mémoire',
'memory.search': 'Rechercher dans la mémoire…',
'memory.noResults': 'Aucun souvenir trouvé',
@@ -717,6 +796,7 @@ const messages: TranslationMap = {
'team.failedChangeRole': 'Échec du changement de rôle',
'team.failedRemoveMember': 'Échec de la suppression du membre',
'devOptions.title': 'Avancé',
'devOptions.titleDiagnostics': 'Développeur et Diagnostics',
'devOptions.diagnostics': 'Diagnostics',
'devOptions.diagnosticsDesc': 'Santé du système, journaux et métriques de performance',
'devOptions.toolPolicyDiagnosticsDesc':
@@ -1868,7 +1948,7 @@ const messages: TranslationMap = {
'chat.safetyTimeout':
"Aucune réponse de l'agent après 2 minutes. Réessaie ou vérifie ta connexion.",
'chat.filter.general': 'Général',
'chat.filter.subconscious': 'Subconscient',
'chat.filter.subconscious': 'Activité en arrière-plan',
'chat.filter.meetings': 'Réunions',
'chat.filter.tasks': 'Tâches',
'chat.selectThread': 'Sélectionne un fil',
@@ -3920,6 +4000,12 @@ const messages: TranslationMap = {
'settings.developerMenu.autonomy.title': 'Autonomie de lagent',
'settings.developerMenu.autonomy.desc':
'Limites de fréquence des actions des outils et seuils de sécurité',
'settings.developerMenu.autocomplete.title': 'Autocomplétion',
'settings.developerMenu.autocomplete.desc':
'Paramètres dautocomplétion IA en ligne et panneau de débogage',
'settings.developerMenu.voiceDebug.title': 'Voix (débogage)',
'settings.developerMenu.voiceDebug.desc':
'État dexécution de la dictée vocale et paramètres de débogage',
'settings.mcpServer.title': 'Serveur MCP',
'settings.mcpServer.toolsSectionTitle': 'Outils disponibles',
'settings.mcpServer.toolsSectionDesc':
@@ -4023,6 +4109,27 @@ const messages: TranslationMap = {
'settings.agentAccess.approvalHistoryDesc':
"Consultez les décisions Approuver / Refuser passées demandées par l'agent.",
'settings.agentAccess.viewApprovalHistory': "Voir l'historique des approbations",
// ── Panneau des autorisations ─────────────────────────────────────
'settings.permissions.title': 'Autorisations',
'settings.permissions.menuDesc':
'Choisissez ce que votre assistant peut faire et où il peut travailler.',
'settings.permissions.accessMode': "Que peut faire l'assistant ?",
'settings.permissions.accessModeDesc':
"Choisissez la liberté accordée à l'assistant lorsqu'il effectue des actions sur votre ordinateur.",
'settings.permissions.preset.readonly.title': 'Regarder sans toucher',
'settings.permissions.preset.readonly.desc':
"L'assistant peut lire des fichiers et explorer, mais ne peut jamais écrire, modifier ou exécuter quoi que ce soit qui change l'état.",
'settings.permissions.preset.supervised.title': "M'interroger d'abord",
'settings.permissions.preset.supervised.desc':
"Peut créer de nouveaux fichiers librement, mais demande toujours votre approbation avant de modifier, d'exécuter des commandes ou d'accéder au réseau.",
'settings.permissions.preset.full.title': 'Contrôle total',
'settings.permissions.preset.full.desc':
"Fonctionne avec votre accès de compte complet. Les commandes destructives, l'accès réseau et les installations demandent toujours une approbation.",
'settings.permissions.folders': 'Où peut-il travailler ?',
'settings.permissions.foldersDesc':
"Le dossier par défaut que l'assistant lit et écrit. Vous pouvez ajouter d'autres dossiers dans les paramètres avancés.",
'settings.sandbox.title': 'Exécution en sandbox',
'settings.sandbox.menuDesc':
"Configurer les backends sandbox pour l'isolation des outils de l'agent.",
@@ -4247,7 +4354,7 @@ const messages: TranslationMap = {
'skills.channelIcon.telegram': 'Telegram',
'skills.channelIcon.web': 'Web',
'skills.channelIcon.yuanbao': 'Yuanbao',
'skills.composio.poweredBy': 'Propulsé par Composio',
'skills.composio.poweredBy': 'OAuth',
'skills.composio.staleStatusTitle': 'Les connexions affichent un état obsolète',
'skills.create.allowedTools': 'Outils autorisés',
'skills.create.allowedToolsHelp': 'Rendu dans le frontmatter SKILL.md comme',
@@ -4893,6 +5000,12 @@ const messages: TranslationMap = {
'autocomplete.maxChars': 'Caractères de contexte maximum',
'autocomplete.overlayTtlMs': "Délai d'affichage (ms)",
'memory.tab.council': 'Council',
'activity.tabs.automations': 'Automatisations',
'activity.tabs.automationsDescription':
"Procédures réutilisables et exécutables — un objectif et les étapes pour l'atteindre.",
'activity.tabs.backgroundActivity': 'Activité en arrière-plan',
'activity.tabs.alerts': 'Alertes',
'intelligence.agents.title': "Bibliothèque d'agents",
'intelligence.agents.subtitle':
'Inspectez les spécialistes exécutables et envoyez une tâche à un agent nommé.',
+111 -5
View File
@@ -6,6 +6,11 @@ const messages: TranslationMap = {
'nav.home': 'होम',
'nav.human': 'मानव',
'nav.chat': 'चैट',
'nav.assistant': 'सहायक',
'assistant.faceMode.on': 'Tiny से बात हो रही है',
'assistant.faceMode.off': 'Tiny से बात करें',
'assistant.faceMode.turnOn': 'मैस्कट दिखाएं',
'assistant.faceMode.turnOff': 'मैस्कट छुपाएं',
'nav.connections': 'कनेक्शन',
'nav.memory': 'इंटेलिजेंस',
'nav.alerts': 'अलर्ट',
@@ -15,6 +20,12 @@ const messages: TranslationMap = {
'nav.switchAgentProfile': 'एजेंट प्रोफाइल बदलें',
'nav.defaultAgentProfile': 'डिफॉल्ट एजेंट',
'nav.noAgentProfiles': 'कोई एजेंट प्रोफाइल नहीं मिला',
'nav.activity': 'गतिविधि',
'nav.avatarMenu.account': 'खाता',
'nav.avatarMenu.billing': 'बिलिंग',
'nav.avatarMenu.rewards': 'रिवॉर्ड',
'nav.avatarMenu.invites': 'दोस्त को आमंत्रित करें',
'nav.avatarMenu.wallet': 'वॉलेट',
'common.cancel': 'रद्द करें',
'common.save': 'सेव करें',
'common.confirm': 'कन्फर्म करें',
@@ -59,6 +70,51 @@ const messages: TranslationMap = {
'common.comingSoon': 'जल्द आ रहा है',
'common.breadcrumb': 'ब्रेडक्रंब',
'settings.general': 'सामान्य',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'खाता',
'settings.groups.assistant': 'सहायक',
'settings.groups.privacySecurity': 'गोपनीयता और सुरक्षा',
'settings.groups.notifications': 'सूचनाएं',
'settings.groups.about': 'के बारे में',
'settings.assistant.personality': 'व्यक्तित्व',
'settings.assistant.personalityDesc': 'नाम, विवरण और SOUL.md व्यक्तित्व',
'settings.assistant.voice': 'आवाज़',
'settings.assistant.voiceDesc': 'स्पीच-टू-टेक्स्ट और टेक्स्ट-टू-स्पीच सेटिंग्स',
'settings.assistant.faceMascot': 'चेहरा / शुभंकर',
'settings.assistant.faceMascotDesc': 'ऐप में उपयोग किया जाने वाला शुभंकर रंग चुनें',
'settings.assistant.backgroundActivity': 'पृष्ठभूमि गतिविधि',
'settings.assistant.backgroundActivityDesc':
'नियंत्रित करें कि आपका सहायक पृष्ठभूमि में कितना सक्रिय काम करे',
'settings.assistant.screenAwareness': 'स्क्रीन जागरूकता',
'settings.assistant.screenAwarenessDesc': 'सहायक को आपकी सक्रिय विंडो देखने दें',
'settings.assistant.desktopCompanion': 'डेस्कटॉप साथी',
'settings.assistant.desktopCompanionDesc': 'सिस्टम ट्रे शॉर्टकट के साथ हमेशा-चालू साथी मोड',
'settings.assistant.permissions': 'अनुमतियाँ',
'settings.assistant.permissionsDesc': 'चुनें कि सहायक क्या कर सकता है और कहाँ काम कर सकता है',
'settings.privacySecurity.privacy': 'गोपनीयता',
'settings.privacySecurity.privacyDesc': 'नियंत्रित करें कि आपके कंप्यूटर से कौन सा डेटा जाता है',
'settings.privacySecurity.security': 'सुरक्षा',
'settings.privacySecurity.securityDesc': 'सत्र और साइन-इन विकल्प',
'settings.privacySecurity.approvalsHistory': 'अनुमोदन और इतिहास',
'settings.privacySecurity.approvalsHistoryDesc': 'हाल के टूल-अनुमोदन निर्णय देखें',
'settings.notifications.menuTitle': 'सूचनाएं',
'settings.notifications.menuDesc': 'अलर्ट इनबॉक्स और सूचना प्राथमिकताएं',
'settings.devGroups.knowledgeMemory': 'ज्ञान और स्मृति',
'settings.devGroups.agentsAutonomy': 'एजेंट और स्वायत्तता',
'settings.devGroups.modelsInference': 'मॉडल और अनुमान',
'settings.devGroups.automationIntegrations': 'स्वचालन और एकीकरण',
'settings.devGroups.toolsCapabilities': 'उपकरण और क्षमताएं',
'settings.devGroups.council': 'परिषद',
'settings.analysisViews.title': 'विश्लेषण दृश्य',
'settings.analysisViews.menuDesc':
'मेमोरी ग्राफ़ विश्लेषण — डायग्राम, केंद्रीयता, संसक्ति, संबंध, ताज़गी, टाइमलाइन, पथ और नेमस्पेस',
'settings.buildInfo.title': 'बिल्ड / संस्करण जानकारी',
'settings.buildInfo.menuDesc': 'ऐप बिल्ड, संस्करण और कोर कनेक्शन विवरण',
'settings.dataSync.title': 'डेटा सिंक',
'settings.dataSync.menuDesc': 'आपका सहायक क्या सिंक करता है — स्रोत, ताज़गी और स्थिति',
'settings.dataSync.description':
'प्रबंधित करें कि आपके सहायक की मेमोरी में क्या सिंक होता है: हर कनेक्ट किया गया स्रोत उसके अंतिम सिंक समय, कितना सिंक हुआ है और अभी सिंक हो रहा है या नहीं के साथ।',
'settings.devGroups.diagnosticsLogs': 'निदान और लॉग',
'settings.featuresAndAI': 'फीचर्स और AI',
'settings.billingAndRewards': 'बिलिंग और रिवॉर्ड',
'settings.support': 'सपोर्ट',
@@ -86,6 +142,12 @@ const messages: TranslationMap = {
'settings.aboutDesc': 'ऐप वर्जन और सॉफ्टवेयर अपडेट',
'settings.developerOptions': 'एडवांस्ड',
'settings.developerOptionsDesc': 'AI कॉन्फिग, मैसेजिंग चैनल, टूल्स, डायग्नोस्टिक्स और डिबग पैनल',
'settings.developerDiagnostics': 'डेवलपर और डायग्नोस्टिक्स',
'settings.developerDiagnosticsDesc':
'उन्नत डेवलपर टूल्स, डायग्नोस्टिक्स, मेमोरी, एजेंट और डिबग पैनल',
'settings.developerMode.title': 'डेवलपर मोड',
'settings.developerMode.description': 'उन्नत डेवलपर और डायग्नोस्टिक टूल्स दिखाएं',
'settings.developerMode.enabledByBuild': 'डेवलपमेंट बिल्ड में हमेशा चालू',
'settings.clearAppData': 'ऐप डेटा क्लियर करें',
'settings.clearAppDataDesc': 'साइन आउट करें और सारा लोकल ऐप डेटा हमेशा के लिए मिटाएं',
'settings.logOut': 'लॉग आउट',
@@ -97,6 +159,14 @@ const messages: TranslationMap = {
'settings.languageDesc': 'ऐप इंटरफेस की डिस्प्ले भाषा',
'settings.alerts': 'अलर्ट',
'settings.alertsDesc': 'हाल के अलर्ट और इनबॉक्स एक्टिविटी देखें',
'settings.account.profile': 'प्रोफ़ाइल',
'settings.account.profileDesc': 'नाम, ईमेल और अवतार',
'settings.account.devices': 'डिवाइस',
'settings.account.devicesDesc': 'मोबाइल डिवाइस पेयर करें और प्रबंधित करें',
'settings.account.teamMembers': 'टीम और सदस्य',
'settings.account.teamMembersDesc': 'टीम एक्सेस और सदस्य भूमिकाएं प्रबंधित करें',
'settings.account.dataMigration': 'डेटा और माइग्रेशन',
'settings.account.dataMigrationDesc': 'दूसरे असिस्टेंट से मेमरी आयात करें',
'settings.account.recoveryPhrase': 'रिकवरी फ्रेज़',
'settings.account.recoveryPhraseDesc': 'अपना अकाउंट रिकवरी फ्रेज़ देखें और बैकअप लें',
'settings.account.team': 'टीम',
@@ -259,7 +329,7 @@ const messages: TranslationMap = {
'skills.connected': 'कनेक्टेड',
'skills.available': 'उपलब्ध',
'skills.addAccount': 'अकाउंट जोड़ें',
'skills.channels': 'चैनल',
'skills.channels': 'मैसेजिंग',
'skills.explorer.emptyCta': 'URL से इंस्टॉल करें',
'skills.explorer.emptyDescription':
'SKILL.md पैकेज इंस्टॉल करें या Hermes-शैली फ़ोल्डर ~/.openhuman/skills में रखें।',
@@ -287,9 +357,9 @@ const messages: TranslationMap = {
'skills.explorer.installed': 'इंस्टॉल किया गया',
'skills.explorer.install': 'इंस्टॉल करें',
'skills.explorer.installing': 'इंस्टॉल हो रहा है…',
'skills.integrations': 'इंटीग्रेशन',
'skills.integrations': 'ऐप्स',
'skills.integrationsSubtitle':
'क्लाउड-आधारित OAuth कनेक्शन — अपने अकाउंट से साइन इन करें और Composio टोकन ब्रोकर करता है ताकि एजेंट आपकी ओर से पढ़ और कार्य कर सकें। कोई API कुंजी प्रबंधित नहीं करनी।',
'क्लाउड-आधारित OAuth कनेक्शन — अपने अकाउंट से साइन इन करें और टोकन सुरक्षित रूप से प्रबंधित होते है ताकि एजेंट आपकी ओर से पढ़ और कार्य कर सकें। कोई API कुंजी प्रबंधित नहीं करनी।',
'skills.composio.noApiKeyTitle': 'कोई Composio API key कॉन्फ़िगर नहीं है',
'skills.composio.noApiKeyDescription':
'लोकल मोड आपकी अपनी Composio API key का उपयोग करता है। यहाँ integrations जोड़ने से पहले Settings → Advanced → Composio खोलकर key जोड़ें।',
@@ -299,6 +369,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'स्किल',
'skills.tabs.meetings': 'Google Meet बैठकें',
'skills.tabs.mcp': 'MCP सर्वर',
'connections.tabs.apps': 'ऐप्स',
'connections.tabs.messaging': 'मैसेजिंग',
'connections.tabs.tools': 'टूल्स',
'connections.tabs.explorer': 'एक्सप्लोरर',
'connections.tabs.talents': 'टैलेंट',
'memory.title': 'मेमोरी',
'memory.search': 'मेमोरी सर्च करें...',
'memory.noResults': 'कोई मेमोरी नहीं मिली',
@@ -699,6 +774,7 @@ const messages: TranslationMap = {
'team.failedChangeRole': 'भूमिका बदलने में विफल',
'team.failedRemoveMember': 'सदस्य को हटाने में विफल',
'devOptions.title': 'एडवांस्ड',
'devOptions.titleDiagnostics': 'डेवलपर और डायग्नोस्टिक्स',
'devOptions.diagnostics': 'डायग्नोस्टिक्स',
'devOptions.diagnosticsDesc': 'सिस्टम हेल्थ, लॉग्स और परफॉर्मेंस मेट्रिक्स',
'devOptions.toolPolicyDiagnosticsDesc':
@@ -1821,7 +1897,7 @@ const messages: TranslationMap = {
'chat.safetyTimeout':
'2 मिनट बाद भी एजेंट से कोई जवाब नहीं मिला। दोबारा कोशिश करें या अपना कनेक्शन चेक करें।',
'chat.filter.general': 'सामान्य',
'chat.filter.subconscious': 'सबकॉन्शस',
'chat.filter.subconscious': 'पृष्ठभूमि गतिविधि',
'chat.filter.meetings': 'मीटिंग',
'chat.filter.tasks': 'टास्क',
'chat.selectThread': 'एक थ्रेड चुनें',
@@ -3841,6 +3917,10 @@ const messages: TranslationMap = {
'OpenHuman से कनेक्ट करने के लिए बाहरी MCP क्लाइंट को कॉन्फ़िगर करें',
'settings.developerMenu.autonomy.title': 'एजेंट स्वायत्तता',
'settings.developerMenu.autonomy.desc': 'टूल क्रिया दर सीमाएँ और सुरक्षा सीमाएँ',
'settings.developerMenu.autocomplete.title': 'स्वतः पूर्ण',
'settings.developerMenu.autocomplete.desc': 'AI इनलाइन स्वतः-पूर्ण सेटिंग्स और डिबग पैनल',
'settings.developerMenu.voiceDebug.title': 'वॉइस (डिबग)',
'settings.developerMenu.voiceDebug.desc': 'वॉइस डिक्टेशन रनटाइम स्थिति और डिबग सेटिंग्स',
'settings.mcpServer.title': 'MCP सर्वर',
'settings.mcpServer.toolsSectionTitle': 'उपलब्ध उपकरण',
'settings.mcpServer.toolsSectionDesc':
@@ -3941,6 +4021,26 @@ const messages: TranslationMap = {
'settings.agentAccess.approvalHistoryDesc':
'एजेंट द्वारा अनुरोधित पिछले स्वीकृति/अस्वीकृति निर्णयों की समीक्षा करें।',
'settings.agentAccess.viewApprovalHistory': 'अनुमोदन इतिहास देखें',
// ── अनुमति पैनल ──────────────────────────────────────────────────
'settings.permissions.title': 'अनुमतियाँ',
'settings.permissions.menuDesc': 'चुनें कि आपका सहायक क्या कर सकता है और कहाँ काम कर सकता है।',
'settings.permissions.accessMode': 'सहायक क्या कर सकता है?',
'settings.permissions.accessModeDesc':
'चुनें कि आपके कंप्यूटर पर कार्रवाई करते समय सहायक को कितनी स्वतंत्रता है।',
'settings.permissions.preset.readonly.title': 'देखो, छुओ मत',
'settings.permissions.preset.readonly.desc':
'सहायक फ़ाइलें पढ़ और खोज सकता है — लेकिन कभी लिखेगा, संपादित करेगा या ऐसा कुछ नहीं चलाएगा जो स्थिति बदले।',
'settings.permissions.preset.supervised.title': 'पहले पूछो',
'settings.permissions.preset.supervised.desc':
'नई फ़ाइलें स्वतंत्र रूप से बना सकता है, लेकिन संपादन, कमांड चलाने या नेटवर्क तक पहुँचने से पहले हमेशा आपकी अनुमति माँगेगा।',
'settings.permissions.preset.full.title': 'पूर्ण नियंत्रण',
'settings.permissions.preset.full.desc':
'आपके पूर्ण खाता पहुँच के साथ काम करता है। विनाशकारी कमांड, नेटवर्क एक्सेस और इंस्टॉलेशन फिर भी अनुमोदन माँगेंगे।',
'settings.permissions.folders': 'यह कहाँ काम कर सकता है?',
'settings.permissions.foldersDesc':
'डिफ़ॉल्ट फ़ोल्डर जिसे सहायक पढ़ता और लिखता है। आप उन्नत सेटिंग्स में और फ़ोल्डर जोड़ सकते हैं।',
'settings.sandbox.title': 'Sandbox निष्पादन',
'settings.sandbox.menuDesc': 'एजेंट टूल आइसोलेशन के लिए Sandbox बैकएंड कॉन्फ़िगर करें।',
'settings.sandbox.loading': 'लोड हो रहा है…',
@@ -4156,7 +4256,7 @@ const messages: TranslationMap = {
'skills.channelIcon.telegram': 'Telegram',
'skills.channelIcon.web': 'वेब',
'skills.channelIcon.yuanbao': 'Yuanbao',
'skills.composio.poweredBy': 'Composio द्वारा संचालित',
'skills.composio.poweredBy': 'OAuth',
'skills.composio.staleStatusTitle': 'कनेक्शन पुरानी स्थिति दिखा रहे हैं',
'skills.create.allowedTools': 'अनुमत टूल्स',
'skills.create.allowedToolsHelp': 'SKILL.md फ्रंटमैटर में प्रस्तुत किया गया',
@@ -4786,6 +4886,12 @@ const messages: TranslationMap = {
'autocomplete.maxChars': 'अधिकतम संदर्भ वर्ण',
'autocomplete.overlayTtlMs': 'ओवरले समय-समाप्ति (ms)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'स्वचालन',
'activity.tabs.automationsDescription':
'पुन: उपयोग योग्य, चलाने योग्य प्रक्रियाएँ — एक लक्ष्य और उसे प्राप्त करने के चरण।',
'activity.tabs.backgroundActivity': 'पृष्ठभूमि गतिविधि',
'activity.tabs.alerts': 'अलर्ट',
'intelligence.agents.title': 'एजेंट लाइब्रेरी',
'intelligence.agents.subtitle':
'चलाए जा सकने वाले विशेषज्ञ देखें और किसी नामित एजेंट को एक काम भेजें।',
+115 -5
View File
@@ -6,6 +6,11 @@ const messages: TranslationMap = {
'nav.home': 'Beranda',
'nav.human': 'Manusia',
'nav.chat': 'Obrolan',
'nav.assistant': 'Asisten',
'assistant.faceMode.on': 'Berbicara dengan Tiny',
'assistant.faceMode.off': 'Bicara dengan Tiny',
'assistant.faceMode.turnOn': 'Tampilkan maskot',
'assistant.faceMode.turnOff': 'Sembunyikan maskot',
'nav.connections': 'Koneksi',
'nav.memory': 'Memori',
'nav.alerts': 'Peringatan',
@@ -15,6 +20,12 @@ const messages: TranslationMap = {
'nav.switchAgentProfile': 'Ganti profil agen',
'nav.defaultAgentProfile': 'Agen default',
'nav.noAgentProfiles': 'Profil agen tidak ditemukan',
'nav.activity': 'Aktivitas',
'nav.avatarMenu.account': 'Akun',
'nav.avatarMenu.billing': 'Tagihan',
'nav.avatarMenu.rewards': 'Hadiah',
'nav.avatarMenu.invites': 'Undang teman',
'nav.avatarMenu.wallet': 'Dompet',
'common.cancel': 'Batal',
'common.save': 'Simpan',
'common.confirm': 'Konfirmasi',
@@ -59,6 +70,53 @@ const messages: TranslationMap = {
'common.comingSoon': 'Segera Hadir',
'common.breadcrumb': 'Breadcrumb',
'settings.general': 'Umum',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Akun',
'settings.groups.assistant': 'Asisten',
'settings.groups.privacySecurity': 'Privasi & Keamanan',
'settings.groups.notifications': 'Notifikasi',
'settings.groups.about': 'Tentang',
'settings.assistant.personality': 'Kepribadian',
'settings.assistant.personalityDesc': 'Nama, deskripsi, dan persona SOUL.md',
'settings.assistant.voice': 'Suara',
'settings.assistant.voiceDesc': 'Pengaturan ucapan-ke-teks dan teks-ke-ucapan',
'settings.assistant.faceMascot': 'Wajah / Maskot',
'settings.assistant.faceMascotDesc': 'Pilih warna maskot yang digunakan di seluruh aplikasi',
'settings.assistant.backgroundActivity': 'Aktivitas latar belakang',
'settings.assistant.backgroundActivityDesc':
'Kontrol seberapa aktif asisten Anda bekerja di latar belakang',
'settings.assistant.screenAwareness': 'Kesadaran layar',
'settings.assistant.screenAwarenessDesc': 'Biarkan asisten melihat jendela aktif Anda',
'settings.assistant.desktopCompanion': 'Pendamping desktop',
'settings.assistant.desktopCompanionDesc':
'Mode pendamping selalu aktif dengan pintasan baki sistem',
'settings.assistant.permissions': 'Izin',
'settings.assistant.permissionsDesc':
'Pilih apa yang dapat dilakukan asisten dan di mana ia dapat bekerja',
'settings.privacySecurity.privacy': 'Privasi',
'settings.privacySecurity.privacyDesc': 'Kontrol data apa yang meninggalkan komputer Anda',
'settings.privacySecurity.security': 'Keamanan',
'settings.privacySecurity.securityDesc': 'Sesi dan opsi masuk',
'settings.privacySecurity.approvalsHistory': 'Persetujuan & riwayat',
'settings.privacySecurity.approvalsHistoryDesc': 'Tinjau keputusan persetujuan alat terbaru',
'settings.notifications.menuTitle': 'Notifikasi',
'settings.notifications.menuDesc': 'Kotak masuk peringatan dan preferensi notifikasi',
'settings.devGroups.knowledgeMemory': 'Pengetahuan & Memori',
'settings.devGroups.agentsAutonomy': 'Agen & Otonomi',
'settings.devGroups.modelsInference': 'Model & Inferensi',
'settings.devGroups.automationIntegrations': 'Otomasi & Integrasi',
'settings.devGroups.toolsCapabilities': 'Alat & Kemampuan',
'settings.devGroups.council': 'Dewan',
'settings.analysisViews.title': 'Tampilan analisis',
'settings.analysisViews.menuDesc':
'Analisis grafik memori — diagram, sentralitas, kohesi, asosiasi, kesegaran, lini masa, jalur, dan namespace',
'settings.buildInfo.title': 'Info build / versi',
'settings.buildInfo.menuDesc': 'Build aplikasi, versi, dan detail koneksi core',
'settings.dataSync.title': 'Sinkronisasi Data',
'settings.dataSync.menuDesc': 'Yang disinkronkan asisten Anda — sumber, kesegaran, dan status',
'settings.dataSync.description':
'Kelola apa yang disinkronkan ke memori asisten Anda: setiap sumber terhubung dengan waktu sinkronisasi terakhirnya, seberapa banyak yang disinkronkan, dan apakah sedang menyinkronkan sekarang.',
'settings.devGroups.diagnosticsLogs': 'Diagnostik & Log',
'settings.featuresAndAI': 'Fitur & AI',
'settings.billingAndRewards': 'Tagihan & Hadiah',
'settings.support': 'Dukungan',
@@ -86,6 +144,12 @@ const messages: TranslationMap = {
'settings.aboutDesc': 'Versi aplikasi dan pembaruan perangkat lunak',
'settings.developerOptions': 'Opsi Developer',
'settings.developerOptionsDesc': 'Diagnostik, panel debug, webhook, dan inspeksi memori',
'settings.developerDiagnostics': 'Developer & Diagnostik',
'settings.developerDiagnosticsDesc':
'Alat developer lanjutan, diagnostik, memori, agen, dan panel debug',
'settings.developerMode.title': 'Mode developer',
'settings.developerMode.description': 'Tampilkan alat developer & diagnostik lanjutan',
'settings.developerMode.enabledByBuild': 'Selalu aktif di build pengembangan',
'settings.clearAppData': 'Bersihkan Data Aplikasi',
'settings.clearAppDataDesc': 'Keluar dan hapus permanen semua data aplikasi lokal',
'settings.logOut': 'Keluar',
@@ -97,6 +161,14 @@ const messages: TranslationMap = {
'settings.languageDesc': 'Bahasa tampilan untuk antarmuka aplikasi',
'settings.alerts': 'Peringatan',
'settings.alertsDesc': 'Lihat peringatan terbaru dan aktivitas di kotak masuk Anda',
'settings.account.profile': 'Profil',
'settings.account.profileDesc': 'Nama, email, dan avatar',
'settings.account.devices': 'Perangkat',
'settings.account.devicesDesc': 'Pasangkan dan kelola perangkat seluler',
'settings.account.teamMembers': 'Tim & anggota',
'settings.account.teamMembersDesc': 'Kelola akses tim dan peran anggota',
'settings.account.dataMigration': 'Data & migrasi',
'settings.account.dataMigrationDesc': 'Impor memori dari asisten lain',
'settings.account.recoveryPhrase': 'Frasa Pemulihan',
'settings.account.recoveryPhraseDesc': 'Lihat dan cadangkan frasa pemulihan akun Anda',
'settings.account.team': 'Tim',
@@ -261,7 +333,7 @@ const messages: TranslationMap = {
'skills.connected': 'Terhubung',
'skills.available': 'Tersedia',
'skills.addAccount': 'Tambah Akun',
'skills.channels': 'Kanal',
'skills.channels': 'Pesan',
'skills.explorer.emptyCta': 'Instal dari URL',
'skills.explorer.emptyDescription':
'Instal paket SKILL.md atau letakkan folder bergaya Hermes di ~/.openhuman/skills.',
@@ -289,9 +361,9 @@ const messages: TranslationMap = {
'skills.explorer.installed': 'Terpasang',
'skills.explorer.install': 'Pasang',
'skills.explorer.installing': 'Memasang…',
'skills.integrations': 'Integrasi',
'skills.integrations': 'Aplikasi',
'skills.integrationsSubtitle':
'Koneksi OAuth berbasis cloud — masuk dengan akun Anda dan Composio mengelola token agar agen dapat membaca dan bertindak atas nama Anda. Tidak perlu mengelola API key.',
'Koneksi OAuth berbasis cloud — masuk dengan akun Anda dan token dikelola dengan aman agar agen dapat membaca dan bertindak atas nama Anda. Tidak perlu mengelola API key.',
'skills.composio.noApiKeyTitle': 'Belum ada API key Composio yang dikonfigurasi',
'skills.composio.noApiKeyDescription':
'Mode lokal menggunakan API key Composio milik Anda sendiri. Buka Pengaturan → Lanjutan → Composio untuk menambahkannya sebelum menghubungkan integrasi di sini.',
@@ -301,6 +373,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'Skill',
'skills.tabs.meetings': 'Rapat Google Meet',
'skills.tabs.mcp': 'MCP Server',
'connections.tabs.apps': 'Aplikasi',
'connections.tabs.messaging': 'Pesan',
'connections.tabs.tools': 'Alat',
'connections.tabs.explorer': 'Penjelajah',
'connections.tabs.talents': 'Talenta',
'memory.title': 'Memori',
'memory.search': 'Cari memori...',
'memory.noResults': 'Memori tidak ditemukan',
@@ -705,6 +782,7 @@ const messages: TranslationMap = {
'team.failedChangeRole': 'Gagal mengubah peran',
'team.failedRemoveMember': 'Gagal menghapus anggota',
'devOptions.title': 'Opsi Developer',
'devOptions.titleDiagnostics': 'Developer & Diagnostik',
'devOptions.diagnostics': 'Diagnostik',
'devOptions.diagnosticsDesc': 'Kesehatan sistem, log, dan metrik performa',
'devOptions.toolPolicyDiagnosticsDesc':
@@ -1824,7 +1902,7 @@ const messages: TranslationMap = {
'common.enable': 'Aktifkan',
'chat.safetyTimeout': 'Tidak ada respons dari agen setelah 2 menit. Coba lagi atau cek koneksi.',
'chat.filter.general': 'Umum',
'chat.filter.subconscious': 'Bawah sadar',
'chat.filter.subconscious': 'Aktivitas latar belakang',
'chat.filter.meetings': 'Rapat',
'chat.filter.tasks': 'Tugas',
'chat.selectThread': 'Pilih thread',
@@ -3849,6 +3927,11 @@ const messages: TranslationMap = {
'Konfigurasikan klien MCP eksternal untuk terhubung ke OpenHuman',
'settings.developerMenu.autonomy.title': 'Otonomi agen',
'settings.developerMenu.autonomy.desc': 'Batas laju aksi alat dan ambang keamanan',
'settings.developerMenu.autocomplete.title': 'Pelengkap otomatis',
'settings.developerMenu.autocomplete.desc':
'Pengaturan pelengkap otomatis AI inline dan panel debug',
'settings.developerMenu.voiceDebug.title': 'Suara (debug)',
'settings.developerMenu.voiceDebug.desc': 'Status runtime dikte suara dan pengaturan debug',
'settings.mcpServer.title': 'Server MCP',
'settings.mcpServer.toolsSectionTitle': 'Alat yang tersedia',
'settings.mcpServer.toolsSectionDesc':
@@ -3950,6 +4033,27 @@ const messages: TranslationMap = {
'settings.agentAccess.approvalHistoryDesc':
'Tinjau keputusan Setuju / Tolak yang diminta oleh agen sebelumnya.',
'settings.agentAccess.viewApprovalHistory': 'Lihat riwayat persetujuan',
// ── Panel Izin ────────────────────────────────────────────────────
'settings.permissions.title': 'Izin',
'settings.permissions.menuDesc':
'Pilih apa yang dapat dilakukan asisten Anda dan di mana ia dapat bekerja.',
'settings.permissions.accessMode': 'Apa yang bisa dilakukan asisten?',
'settings.permissions.accessModeDesc':
'Pilih seberapa banyak kebebasan yang dimiliki asisten saat mengambil tindakan di komputer Anda.',
'settings.permissions.preset.readonly.title': 'Lihat, jangan sentuh',
'settings.permissions.preset.readonly.desc':
'Asisten dapat membaca file dan menjelajah — tetapi tidak pernah menulis, mengedit, atau menjalankan apa pun yang mengubah status.',
'settings.permissions.preset.supervised.title': 'Tanya saya dulu',
'settings.permissions.preset.supervised.desc':
'Dapat membuat file baru secara bebas, tetapi selalu meminta persetujuan Anda sebelum mengedit, menjalankan perintah, atau mengakses jaringan.',
'settings.permissions.preset.full.title': 'Kontrol penuh',
'settings.permissions.preset.full.desc':
'Berjalan dengan akses akun penuh Anda. Perintah destruktif, akses jaringan, dan instalasi masih meminta persetujuan.',
'settings.permissions.folders': 'Di mana bisa bekerja?',
'settings.permissions.foldersDesc':
'Folder default yang dibaca dan ditulis asisten. Anda dapat menambahkan folder lain di pengaturan Lanjutan.',
'settings.sandbox.title': 'Eksekusi sandbox',
'settings.sandbox.menuDesc': 'Konfigurasikan backend sandbox untuk isolasi alat agen.',
'settings.sandbox.loading': 'Memuat…',
@@ -4166,7 +4270,7 @@ const messages: TranslationMap = {
'skills.channelIcon.telegram': 'Telegram',
'skills.channelIcon.web': 'Web',
'skills.channelIcon.yuanbao': 'Yuanbao',
'skills.composio.poweredBy': 'Didukung oleh Composio',
'skills.composio.poweredBy': 'OAuth',
'skills.composio.staleStatusTitle': 'Koneksi menunjukkan status basi',
'skills.create.allowedTools': 'Tool yang diizinkan',
'skills.create.allowedToolsHelp': 'Dirender ke frontmatter SKILL.md sebagai',
@@ -4796,6 +4900,12 @@ const messages: TranslationMap = {
'autocomplete.maxChars': 'Karakter konteks maks',
'autocomplete.overlayTtlMs': 'Batas waktu overlay (md)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'Otomatisasi',
'activity.tabs.automationsDescription':
'Prosedur yang dapat digunakan kembali dan dijalankan — tujuan beserta langkah-langkah untuk mencapainya.',
'activity.tabs.backgroundActivity': 'Aktivitas latar belakang',
'activity.tabs.alerts': 'Peringatan',
'intelligence.agents.title': 'Pustaka Agen',
'intelligence.agents.subtitle':
'Periksa spesialis yang dapat dijalankan dan kirim satu tugas ke agen tertentu.',
+115 -5
View File
@@ -6,6 +6,11 @@ const messages: TranslationMap = {
'nav.home': 'Home',
'nav.human': 'Umano',
'nav.chat': 'Chat',
'nav.assistant': 'Assistente',
'assistant.faceMode.on': 'Sta parlando con Tiny',
'assistant.faceMode.off': 'Parla con Tiny',
'assistant.faceMode.turnOn': 'Mostra mascotte',
'assistant.faceMode.turnOff': 'Nascondi mascotte',
'nav.connections': 'Connessioni',
'nav.memory': 'Intelligenza',
'nav.alerts': 'Avvisi',
@@ -15,6 +20,12 @@ const messages: TranslationMap = {
'nav.switchAgentProfile': 'Cambia profilo agente',
'nav.defaultAgentProfile': 'Agente predefinito',
'nav.noAgentProfiles': 'Nessun profilo agente trovato',
'nav.activity': 'Attività',
'nav.avatarMenu.account': 'Account',
'nav.avatarMenu.billing': 'Fatturazione',
'nav.avatarMenu.rewards': 'Premi',
'nav.avatarMenu.invites': 'Invita un amico',
'nav.avatarMenu.wallet': 'Portafoglio',
'common.cancel': 'Annulla',
'common.save': 'Salva',
'common.confirm': 'Conferma',
@@ -59,6 +70,53 @@ const messages: TranslationMap = {
'common.comingSoon': 'Prossimamente',
'common.breadcrumb': 'breadcrumb',
'settings.general': 'Generale',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Account',
'settings.groups.assistant': 'Assistente',
'settings.groups.privacySecurity': 'Privacy e sicurezza',
'settings.groups.notifications': 'Notifiche',
'settings.groups.about': 'Informazioni',
'settings.assistant.personality': 'Personalità',
'settings.assistant.personalityDesc': 'Nome, descrizione e persona SOUL.md',
'settings.assistant.voice': 'Voce',
'settings.assistant.voiceDesc': 'Impostazioni di sintesi vocale e riconoscimento vocale',
'settings.assistant.faceMascot': 'Faccia / Mascotte',
'settings.assistant.faceMascotDesc': "Scegli il colore della mascotte usato nell'app",
'settings.assistant.backgroundActivity': 'Attività in background',
'settings.assistant.backgroundActivityDesc':
'Controlla quanto attivamente il tuo assistente lavora in background',
'settings.assistant.screenAwareness': 'Consapevolezza dello schermo',
'settings.assistant.screenAwarenessDesc': "Consenti all'assistente di vedere la finestra attiva",
'settings.assistant.desktopCompanion': 'Compagno desktop',
'settings.assistant.desktopCompanionDesc':
'Modalità compagno sempre attiva con scorciatoia nella barra di sistema',
'settings.assistant.permissions': 'Autorizzazioni',
'settings.assistant.permissionsDesc': "Scegli cosa può fare l'assistente e dove può lavorare",
'settings.privacySecurity.privacy': 'Privacy',
'settings.privacySecurity.privacyDesc': 'Controlla quali dati lasciano il tuo computer',
'settings.privacySecurity.security': 'Sicurezza',
'settings.privacySecurity.securityDesc': 'Sessioni e opzioni di accesso',
'settings.privacySecurity.approvalsHistory': 'Approvazioni e cronologia',
'settings.privacySecurity.approvalsHistoryDesc':
'Esamina le decisioni di approvazione degli strumenti recenti',
'settings.notifications.menuTitle': 'Notifiche',
'settings.notifications.menuDesc': 'Posta in arrivo degli avvisi e preferenze di notifica',
'settings.devGroups.knowledgeMemory': 'Conoscenza e memoria',
'settings.devGroups.agentsAutonomy': 'Agenti e autonomia',
'settings.devGroups.modelsInference': 'Modelli e inferenza',
'settings.devGroups.automationIntegrations': 'Automazione e integrazioni',
'settings.devGroups.toolsCapabilities': 'Strumenti e capacità',
'settings.devGroups.council': 'Consiglio',
'settings.analysisViews.title': 'Viste di analisi',
'settings.analysisViews.menuDesc':
'Analisi del grafo di memoria — diagramma, centralità, coesione, associazioni, freschezza, cronologia, percorsi e namespace',
'settings.buildInfo.title': 'Info build/versione',
'settings.buildInfo.menuDesc': 'Build dellapp, versione e dettagli di connessione del core',
'settings.dataSync.title': 'Sincronizzazione dati',
'settings.dataSync.menuDesc': 'Ciò che il tuo assistente sincronizza — fonti, freschezza e stato',
'settings.dataSync.description':
"Gestisci ciò che viene sincronizzato nella memoria del tuo assistente: ogni fonte connessa con l'ora dell'ultima sincronizzazione, quanto è sincronizzato e se sta sincronizzando adesso.",
'settings.devGroups.diagnosticsLogs': 'Diagnostica e registri',
'settings.featuresAndAI': 'Funzionalità e AI',
'settings.billingAndRewards': 'Fatturazione e premi',
'settings.support': 'Supporto',
@@ -87,6 +145,12 @@ const messages: TranslationMap = {
'settings.developerOptions': 'Avanzate',
'settings.developerOptionsDesc':
'Configurazione AI, canali di messaggistica, strumenti, diagnostica e pannelli di debug',
'settings.developerDiagnostics': 'Sviluppatore e Diagnostica',
'settings.developerDiagnosticsDesc':
'Strumenti avanzati per sviluppatori, diagnostica, memoria, agenti e pannelli di debug',
'settings.developerMode.title': 'Modalità sviluppatore',
'settings.developerMode.description': 'Mostra strumenti avanzati per sviluppatori e diagnostica',
'settings.developerMode.enabledByBuild': 'Sempre attiva nelle build di sviluppo',
'settings.clearAppData': 'Cancella dati app',
'settings.clearAppDataDesc':
"Disconnetti e cancella permanentemente tutti i dati locali dell'app",
@@ -99,6 +163,14 @@ const messages: TranslationMap = {
'settings.languageDesc': "Lingua di visualizzazione dell'interfaccia dell'app",
'settings.alerts': 'Avvisi',
'settings.alertsDesc': 'Visualizza avvisi recenti e attività nella tua posta',
'settings.account.profile': 'Profilo',
'settings.account.profileDesc': 'Nome, email e avatar',
'settings.account.devices': 'Dispositivi',
'settings.account.devicesDesc': 'Associa e gestisci dispositivi mobili',
'settings.account.teamMembers': 'Team & membri',
'settings.account.teamMembersDesc': 'Gestisci accesso al team e ruoli dei membri',
'settings.account.dataMigration': 'Dati & migrazione',
'settings.account.dataMigrationDesc': 'Importa memoria da un altro assistente',
'settings.account.recoveryPhrase': 'Frase di recupero',
'settings.account.recoveryPhraseDesc': 'Visualizza e fai il backup della frase di recupero',
'settings.account.team': 'Squadra',
@@ -265,7 +337,7 @@ const messages: TranslationMap = {
'skills.connected': 'Connesso',
'skills.available': 'Disponibile',
'skills.addAccount': 'Aggiungi account',
'skills.channels': 'Canali',
'skills.channels': 'Messaggistica',
'skills.explorer.emptyCta': 'Installa da URL',
'skills.explorer.emptyDescription':
'Installa un pacchetto SKILL.md o inserisci cartelle in stile Hermes in ~/.openhuman/skills.',
@@ -293,9 +365,9 @@ const messages: TranslationMap = {
'skills.explorer.installed': 'Installata',
'skills.explorer.install': 'Installa',
'skills.explorer.installing': 'Installazione…',
'skills.integrations': 'Integrazioni',
'skills.integrations': 'App',
'skills.integrationsSubtitle':
'Connessioni OAuth basate su cloud — accedi con il tuo account e Composio gestisce i token affinché gli agenti possano leggere e agire per tuo conto. Nessuna chiave API da gestire.',
'Connessioni OAuth basate su cloud — accedi con il tuo account e i token sono gestiti in modo sicuro affinché gli agenti possano leggere e agire per tuo conto. Nessuna chiave API da gestire.',
'skills.composio.noApiKeyTitle': 'Nessuna chiave API Composio configurata',
'skills.composio.noApiKeyDescription':
'La modalità locale usa la tua chiave API Composio. Apri Impostazioni → Avanzate → Composio per aggiungerne una prima di collegare le integrazioni qui.',
@@ -305,6 +377,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'Skill',
'skills.tabs.meetings': 'Riunioni Google Meet',
'skills.tabs.mcp': 'MCP Server',
'connections.tabs.apps': 'App',
'connections.tabs.messaging': 'Messaggistica',
'connections.tabs.tools': 'Strumenti',
'connections.tabs.explorer': 'Esplora',
'connections.tabs.talents': 'Talenti',
'memory.title': 'Memoria',
'memory.search': 'Cerca memorie...',
'memory.noResults': 'Nessuna memoria trovata',
@@ -713,6 +790,7 @@ const messages: TranslationMap = {
'team.failedChangeRole': 'Impossibile modificare il ruolo',
'team.failedRemoveMember': 'Impossibile rimuovere il membro',
'devOptions.title': 'Avanzate',
'devOptions.titleDiagnostics': 'Sviluppatore e Diagnostica',
'devOptions.diagnostics': 'Diagnostica',
'devOptions.diagnosticsDesc': 'Stato del sistema, log e metriche di performance',
'devOptions.toolPolicyDiagnosticsDesc':
@@ -1852,7 +1930,7 @@ const messages: TranslationMap = {
'chat.safetyTimeout':
"Nessuna risposta dall'agente dopo 2 minuti. Riprova o controlla la connessione.",
'chat.filter.general': 'Generale',
'chat.filter.subconscious': 'Subconscio',
'chat.filter.subconscious': 'Attività in background',
'chat.filter.meetings': 'Riunioni',
'chat.filter.tasks': 'Attività',
'chat.selectThread': 'Seleziona un thread',
@@ -3900,6 +3978,12 @@ const messages: TranslationMap = {
'settings.developerMenu.autonomy.title': 'Autonomia agente',
'settings.developerMenu.autonomy.desc':
'Limiti di frequenza delle azioni degli strumenti e soglie di sicurezza',
'settings.developerMenu.autocomplete.title': 'Completamento automatico',
'settings.developerMenu.autocomplete.desc':
'Impostazioni del completamento automatico AI in linea e pannello di debug',
'settings.developerMenu.voiceDebug.title': 'Voce (debug)',
'settings.developerMenu.voiceDebug.desc':
'Stato di runtime della dettatura vocale e impostazioni di debug',
'settings.mcpServer.title': 'Server MCP',
'settings.mcpServer.toolsSectionTitle': 'Strumenti disponibili',
'settings.mcpServer.toolsSectionDesc':
@@ -4003,6 +4087,26 @@ const messages: TranslationMap = {
'settings.agentAccess.approvalHistoryDesc':
"Rivedi le decisioni Approva / Nega precedenti richieste dall'agente.",
'settings.agentAccess.viewApprovalHistory': 'Visualizza cronologia approvazioni',
// ── Pannello Autorizzazioni ───────────────────────────────────────
'settings.permissions.title': 'Autorizzazioni',
'settings.permissions.menuDesc': 'Scegli cosa può fare il tuo assistente e dove può lavorare.',
'settings.permissions.accessMode': "Cosa può fare l'assistente?",
'settings.permissions.accessModeDesc':
"Scegli quanta libertà ha l'assistente quando esegue azioni sul tuo computer.",
'settings.permissions.preset.readonly.title': 'Guarda, non toccare',
'settings.permissions.preset.readonly.desc':
"L'assistente può leggere file ed esplorare, ma non può mai scrivere, modificare o eseguire nulla che cambi lo stato.",
'settings.permissions.preset.supervised.title': 'Chiedimi prima',
'settings.permissions.preset.supervised.desc':
'Può creare nuovi file liberamente, ma chiede sempre la tua approvazione prima di modificare, eseguire comandi o accedere alla rete.',
'settings.permissions.preset.full.title': 'Controllo completo',
'settings.permissions.preset.full.desc':
"Opera con il tuo pieno accesso all'account. Comandi distruttivi, accesso alla rete e installazioni richiedono ancora approvazione.",
'settings.permissions.folders': 'Dove può lavorare?',
'settings.permissions.foldersDesc':
"La cartella predefinita che l'assistente legge e scrive. Puoi aggiungere altre cartelle nelle impostazioni avanzate.",
'settings.sandbox.title': 'Esecuzione in sandbox',
'settings.sandbox.menuDesc':
"Configura i backend sandbox per l'isolamento degli strumenti agente.",
@@ -4225,7 +4329,7 @@ const messages: TranslationMap = {
'skills.channelIcon.telegram': 'Telegram',
'skills.channelIcon.web': 'Web',
'skills.channelIcon.yuanbao': 'Yuanbao',
'skills.composio.poweredBy': 'Offerto da Composio',
'skills.composio.poweredBy': 'OAuth',
'skills.composio.staleStatusTitle': 'Le connessioni mostrano uno stato obsoleto',
'skills.create.allowedTools': 'Strumenti consentiti',
'skills.create.allowedToolsHelp': 'Resi nel frontmatter SKILL.md come',
@@ -4869,6 +4973,12 @@ const messages: TranslationMap = {
'autocomplete.maxChars': 'Caratteri massimi di contesto',
'autocomplete.overlayTtlMs': 'Timeout overlay (ms)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'Automazioni',
'activity.tabs.automationsDescription':
'Procedure riutilizzabili ed eseguibili — un obiettivo e i passi per raggiungerlo.',
'activity.tabs.backgroundActivity': 'Attività in background',
'activity.tabs.alerts': 'Avvisi',
'intelligence.agents.title': 'Libreria agenti',
'intelligence.agents.subtitle':
'Ispeziona specialisti eseguibili e invia un compito a un agente specifico.',
+113 -5
View File
@@ -6,6 +6,11 @@ const messages: TranslationMap = {
'nav.home': '홈',
'nav.human': '휴먼',
'nav.chat': '채팅',
'nav.assistant': '어시스턴트',
'assistant.faceMode.on': 'Tiny와 대화 중',
'assistant.faceMode.off': 'Tiny와 대화하기',
'assistant.faceMode.turnOn': '마스코트 표시',
'assistant.faceMode.turnOff': '마스코트 숨기기',
'nav.connections': '연결',
'nav.memory': '인텔리전스',
'nav.alerts': '알림',
@@ -15,6 +20,12 @@ const messages: TranslationMap = {
'nav.switchAgentProfile': '에이전트 프로필 전환',
'nav.defaultAgentProfile': '기본 에이전트',
'nav.noAgentProfiles': '에이전트 프로필을 찾을 수 없습니다',
'nav.activity': '활동',
'nav.avatarMenu.account': '계정',
'nav.avatarMenu.billing': '결제',
'nav.avatarMenu.rewards': '보상',
'nav.avatarMenu.invites': '친구 초대',
'nav.avatarMenu.wallet': '지갑',
'common.cancel': '취소',
'common.save': '저장',
'common.confirm': '확인',
@@ -59,6 +70,53 @@ const messages: TranslationMap = {
'common.comingSoon': '출시 예정',
'common.breadcrumb': '탐색경로',
'settings.general': '일반',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': '계정',
'settings.groups.assistant': '어시스턴트',
'settings.groups.privacySecurity': '개인정보 및 보안',
'settings.groups.notifications': '알림',
'settings.groups.about': '정보',
'settings.assistant.personality': '개성',
'settings.assistant.personalityDesc': '이름, 설명 및 SOUL.md 페르소나',
'settings.assistant.voice': '음성',
'settings.assistant.voiceDesc': '음성-텍스트 및 텍스트-음성 설정',
'settings.assistant.faceMascot': '얼굴 / 마스코트',
'settings.assistant.faceMascotDesc': '앱 전체에서 사용되는 마스코트 색상 선택',
'settings.assistant.backgroundActivity': '백그라운드 활동',
'settings.assistant.backgroundActivityDesc':
'어시스턴트가 백그라운드에서 얼마나 활발히 작동하는지 제어',
'settings.assistant.screenAwareness': '화면 인식',
'settings.assistant.screenAwarenessDesc': '어시스턴트가 활성 창을 볼 수 있도록 허용',
'settings.assistant.desktopCompanion': '데스크탑 동반자',
'settings.assistant.desktopCompanionDesc':
'시스템 트레이 단축키가 있는 항상 활성화된 동반자 모드',
'settings.assistant.permissions': '권한',
'settings.assistant.permissionsDesc':
'어시스턴트가 무엇을 할 수 있고 어디서 작업할 수 있는지 선택하세요',
'settings.privacySecurity.privacy': '개인정보',
'settings.privacySecurity.privacyDesc': '컴퓨터를 떠나는 데이터 제어',
'settings.privacySecurity.security': '보안',
'settings.privacySecurity.securityDesc': '세션 및 로그인 옵션',
'settings.privacySecurity.approvalsHistory': '승인 및 기록',
'settings.privacySecurity.approvalsHistoryDesc': '최근 도구 승인 결정 검토',
'settings.notifications.menuTitle': '알림',
'settings.notifications.menuDesc': '알림 받은편지함 및 알림 기본설정',
'settings.devGroups.knowledgeMemory': '지식 및 메모리',
'settings.devGroups.agentsAutonomy': '에이전트 및 자율성',
'settings.devGroups.modelsInference': '모델 및 추론',
'settings.devGroups.automationIntegrations': '자동화 및 통합',
'settings.devGroups.toolsCapabilities': '도구 및 기능',
'settings.devGroups.council': '위원회',
'settings.analysisViews.title': '분석 보기',
'settings.analysisViews.menuDesc':
'메모리 그래프 분석 — 다이어그램, 중심성, 응집도, 연관, 최신성, 타임라인, 경로 및 네임스페이스',
'settings.buildInfo.title': '빌드 / 버전 정보',
'settings.buildInfo.menuDesc': '앱 빌드, 버전 및 코어 연결 세부 정보',
'settings.dataSync.title': '데이터 동기화',
'settings.dataSync.menuDesc': '어시스턴트가 동기화하는 항목 — 소스, 최신성 및 상태',
'settings.dataSync.description':
'어시스턴트의 메모리에 무엇이 동기화되는지 관리하세요: 연결된 각 소스의 마지막 동기화 시간, 동기화된 양, 지금 동기화 중인지 여부를 확인할 수 있습니다.',
'settings.devGroups.diagnosticsLogs': '진단 및 로그',
'settings.featuresAndAI': '기능 및 AI',
'settings.billingAndRewards': '결제 및 보상',
'settings.support': '지원',
@@ -86,6 +144,11 @@ const messages: TranslationMap = {
'settings.aboutDesc': '앱 버전 및 소프트웨어 업데이트',
'settings.developerOptions': '고급',
'settings.developerOptionsDesc': 'AI 구성, 메시징 채널, 도구, 진단 및 디버그 패널',
'settings.developerDiagnostics': '개발자 및 진단',
'settings.developerDiagnosticsDesc': '고급 개발자 도구, 진단, 메모리, 에이전트 및 디버그 패널',
'settings.developerMode.title': '개발자 모드',
'settings.developerMode.description': '고급 개발자 및 진단 도구 표시',
'settings.developerMode.enabledByBuild': '개발 빌드에서 항상 활성화',
'settings.clearAppData': '앱 데이터 삭제',
'settings.clearAppDataDesc': '로그아웃하고 모든 로컬 앱 데이터를 영구적으로 삭제',
'settings.logOut': '로그아웃',
@@ -97,6 +160,14 @@ const messages: TranslationMap = {
'settings.languageDesc': '앱 인터페이스 표시 언어',
'settings.alerts': '알림',
'settings.alertsDesc': '받은 편지함에서 최근 알림 및 활동 보기',
'settings.account.profile': '프로필',
'settings.account.profileDesc': '이름, 이메일, 아바타',
'settings.account.devices': '기기',
'settings.account.devicesDesc': '모바일 기기 연결 및 관리',
'settings.account.teamMembers': '팀 & 멤버',
'settings.account.teamMembersDesc': '팀 접근 및 멤버 역할 관리',
'settings.account.dataMigration': '데이터 & 마이그레이션',
'settings.account.dataMigrationDesc': '다른 어시스턴트에서 메모리 가져오기',
'settings.account.recoveryPhrase': '복구 문구',
'settings.account.recoveryPhraseDesc': '계정 복구 문구를 확인하고 백업합니다',
'settings.account.team': '팀',
@@ -259,7 +330,7 @@ const messages: TranslationMap = {
'skills.connected': '연결됨',
'skills.available': '사용 가능',
'skills.addAccount': '계정 추가',
'skills.channels': '채널',
'skills.channels': '메시징',
'skills.explorer.emptyCta': 'URL에서 설치',
'skills.explorer.emptyDescription':
'SKILL.md 패키지를 설치하거나 Hermes 스타일 폴더를 ~/.openhuman/skills에 넣으세요.',
@@ -287,9 +358,9 @@ const messages: TranslationMap = {
'skills.explorer.installed': '설치됨',
'skills.explorer.install': '설치',
'skills.explorer.installing': '설치 중…',
'skills.integrations': '통합',
'skills.integrations': '',
'skills.integrationsSubtitle':
'클라우드 기반 OAuth 연결 — 계정으로 로그인하면 Composio가 토큰을 관리하여 에이전트가 사용자를 대신해 읽고 작동할 수 있습니다. API 키 관리가 필요 없습니다.',
'클라우드 기반 OAuth 연결 — 계정으로 로그인하면 토큰이 안전하게 관리되어 에이전트가 사용자를 대신해 읽고 작동할 수 있습니다. API 키 관리가 필요 없습니다.',
'skills.composio.noApiKeyTitle': '아니요 Composio API 키가 구성됨',
'skills.composio.noApiKeyDescription':
'로컬 모드에서는 자체 Composio API 키를 사용합니다. 설정 → 고급 → Composio에서 키를 추가한 후 여기서 통합을 연결하세요.',
@@ -299,6 +370,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': '스킬',
'skills.tabs.meetings': 'Google Meet 회의',
'skills.tabs.mcp': 'MCP 서버',
'connections.tabs.apps': '앱',
'connections.tabs.messaging': '메시징',
'connections.tabs.tools': '도구',
'connections.tabs.explorer': '탐색기',
'connections.tabs.talents': '재능',
'memory.title': '메모리',
'memory.search': '메모리 검색...',
'memory.noResults': '메모리를 찾을 수 없습니다',
@@ -696,6 +772,7 @@ const messages: TranslationMap = {
'team.failedChangeRole': '역할 변경 실패',
'team.failedRemoveMember': '구성원 제거 실패',
'devOptions.title': '고급',
'devOptions.titleDiagnostics': '개발자 및 진단',
'devOptions.diagnostics': '진단',
'devOptions.diagnosticsDesc': '시스템 상태, 로그 및 성능 지표',
'devOptions.toolPolicyDiagnosticsDesc': '도구 인벤토리, 정책 상태, MCP 허용 목록 및 최근 차단',
@@ -1800,7 +1877,7 @@ const messages: TranslationMap = {
'common.enable': '활성화',
'chat.safetyTimeout': '2분 후에도 에이전트의 응답이 없습니다. 다시 시도하거나 연결을 확인하세요.',
'chat.filter.general': '일반',
'chat.filter.subconscious': '잠재의식',
'chat.filter.subconscious': '백그라운드 활동',
'chat.filter.meetings': '회의',
'chat.filter.tasks': '작업',
'chat.selectThread': '스레드 선택',
@@ -3795,6 +3872,10 @@ const messages: TranslationMap = {
'settings.developerMenu.mcpServer.desc': 'OpenHuman에 연결하도록 외부 MCP 클라이언트 구성',
'settings.developerMenu.autonomy.title': '에이전트 자율성',
'settings.developerMenu.autonomy.desc': '도구 작업 속도 제한 및 안전 임계값',
'settings.developerMenu.autocomplete.title': '자동 완성',
'settings.developerMenu.autocomplete.desc': 'AI 인라인 자동 완성 설정 및 디버그 패널',
'settings.developerMenu.voiceDebug.title': '음성 (디버그)',
'settings.developerMenu.voiceDebug.desc': '음성 받아쓰기 런타임 상태 및 디버그 설정',
'settings.mcpServer.title': 'MCP 서버',
'settings.mcpServer.toolsSectionTitle': '사용 가능한 도구',
'settings.mcpServer.toolsSectionDesc':
@@ -3893,6 +3974,27 @@ const messages: TranslationMap = {
'settings.agentAccess.approvalHistory': '승인 이력',
'settings.agentAccess.approvalHistoryDesc': '에이전트가 요청한 이전 승인/거부 결정을 검토합니다.',
'settings.agentAccess.viewApprovalHistory': '승인 기록 보기',
// ── 권한 패널 ─────────────────────────────────────────────────────
'settings.permissions.title': '권한',
'settings.permissions.menuDesc':
'어시스턴트가 무엇을 할 수 있고 어디서 작업할 수 있는지 선택하세요.',
'settings.permissions.accessMode': '어시스턴트가 무엇을 할 수 있나요?',
'settings.permissions.accessModeDesc':
'어시스턴트가 컴퓨터에서 작업을 수행할 때 얼마나 많은 자유를 허용할지 선택하세요.',
'settings.permissions.preset.readonly.title': '보기만 가능',
'settings.permissions.preset.readonly.desc':
'어시스턴트는 파일을 읽고 탐색할 수 있지만, 상태를 변경하는 쓰기, 편집 또는 실행은 절대 할 수 없습니다.',
'settings.permissions.preset.supervised.title': '먼저 물어보기',
'settings.permissions.preset.supervised.desc':
'새 파일을 자유롭게 만들 수 있지만, 편집, 명령 실행 또는 네트워크 접근 전에 항상 승인을 요청합니다.',
'settings.permissions.preset.full.title': '전체 제어',
'settings.permissions.preset.full.desc':
'전체 계정 접근 권한으로 실행됩니다. 파괴적 명령, 네트워크 접근 및 설치는 여전히 승인이 필요합니다.',
'settings.permissions.folders': '어디서 작업할 수 있나요?',
'settings.permissions.foldersDesc':
'어시스턴트가 읽고 쓰는 기본 폴더입니다. 고급 설정에서 추가 폴더를 추가할 수 있습니다.',
'settings.sandbox.title': '샌드박스 실행',
'settings.sandbox.menuDesc': '에이전트 도구 격리를 위한 샌드박스 백엔드를 구성합니다.',
'settings.sandbox.loading': '로딩 중…',
@@ -4108,7 +4210,7 @@ const messages: TranslationMap = {
'skills.channelIcon.telegram': 'Telegram',
'skills.channelIcon.web': '웹',
'skills.channelIcon.yuanbao': 'Yuanbao',
'skills.composio.poweredBy': 'Composio 제공',
'skills.composio.poweredBy': 'OAuth',
'skills.composio.staleStatusTitle': '연결이 오래된 상태를 표시합니다.',
'skills.create.allowedTools': '허용된 도구',
'skills.create.allowedToolsHelp': 'SKILL.md 앞부분에 다음과 같이 렌더링됩니다.',
@@ -4733,6 +4835,12 @@ const messages: TranslationMap = {
'autocomplete.maxChars': '최대 컨텍스트 문자 수',
'autocomplete.overlayTtlMs': '오버레이 시간 초과 (ms)',
'memory.tab.council': 'Council',
'activity.tabs.automations': '자동화',
'activity.tabs.automationsDescription':
'재사용 가능하고 실행 가능한 절차 — 목표와 그것을 달성하기 위한 단계.',
'activity.tabs.backgroundActivity': '백그라운드 활동',
'activity.tabs.alerts': '알림',
'intelligence.agents.title': '에이전트 라이브러리',
'intelligence.agents.subtitle':
'실행 가능한 전문가를 살펴보고 지정한 에이전트에 작업을 보냅니다.',
+114 -5
View File
@@ -6,6 +6,11 @@ const messages: TranslationMap = {
'nav.home': 'Start',
'nav.human': 'Człowiek',
'nav.chat': 'Czat',
'nav.assistant': 'Asystent',
'assistant.faceMode.on': 'Rozmawia z Tiny',
'assistant.faceMode.off': 'Porozmawiaj z Tiny',
'assistant.faceMode.turnOn': 'Pokaż maskotka',
'assistant.faceMode.turnOff': 'Ukryj maskotka',
'nav.connections': 'Połączenia',
'nav.memory': 'Inteligencja',
'nav.alerts': 'Alerty',
@@ -15,6 +20,12 @@ const messages: TranslationMap = {
'nav.switchAgentProfile': 'Przełącz profil agenta',
'nav.defaultAgentProfile': 'Domyślny agent',
'nav.noAgentProfiles': 'Nie znaleziono profili agentów',
'nav.activity': 'Aktywność',
'nav.avatarMenu.account': 'Konto',
'nav.avatarMenu.billing': 'Rozliczenia',
'nav.avatarMenu.rewards': 'Nagrody',
'nav.avatarMenu.invites': 'Zaproś znajomego',
'nav.avatarMenu.wallet': 'Portfel',
'common.cancel': 'Anuluj',
'common.save': 'Zapisz',
'common.confirm': 'Potwierdź',
@@ -59,6 +70,52 @@ const messages: TranslationMap = {
'common.comingSoon': 'Wkrótce',
'common.breadcrumb': 'Ścieżka nawigacji',
'settings.general': 'Ogólne',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Konto',
'settings.groups.assistant': 'Asystent',
'settings.groups.privacySecurity': 'Prywatność i bezpieczeństwo',
'settings.groups.notifications': 'Powiadomienia',
'settings.groups.about': 'O aplikacji',
'settings.assistant.personality': 'Osobowość',
'settings.assistant.personalityDesc': 'Nazwa, opis i persona SOUL.md',
'settings.assistant.voice': 'Głos',
'settings.assistant.voiceDesc': 'Ustawienia mowy na tekst i tekstu na mowę',
'settings.assistant.faceMascot': 'Twarz / Maskotka',
'settings.assistant.faceMascotDesc': 'Wybierz kolor maskotki używany w aplikacji',
'settings.assistant.backgroundActivity': 'Aktywność w tle',
'settings.assistant.backgroundActivityDesc': 'Kontroluj, jak aktywnie asystent pracuje w tle',
'settings.assistant.screenAwareness': 'Świadomość ekranu',
'settings.assistant.screenAwarenessDesc': 'Pozwól asystentowi widzieć aktywne okno',
'settings.assistant.desktopCompanion': 'Towarzysz pulpitu',
'settings.assistant.desktopCompanionDesc':
'Tryb towarzyski zawsze aktywny ze skrótem w zasobniku systemowym',
'settings.assistant.permissions': 'Uprawnienia',
'settings.assistant.permissionsDesc': 'Wybierz, co może robić asystent i gdzie może pracować',
'settings.privacySecurity.privacy': 'Prywatność',
'settings.privacySecurity.privacyDesc': 'Kontroluj, jakie dane opuszczają Twój komputer',
'settings.privacySecurity.security': 'Bezpieczeństwo',
'settings.privacySecurity.securityDesc': 'Sesje i opcje logowania',
'settings.privacySecurity.approvalsHistory': 'Zatwierdzenia i historia',
'settings.privacySecurity.approvalsHistoryDesc':
'Przejrzyj ostatnie decyzje zatwierdzenia narzędzi',
'settings.notifications.menuTitle': 'Powiadomienia',
'settings.notifications.menuDesc': 'Skrzynka odbiorcza alertów i preferencje powiadomień',
'settings.devGroups.knowledgeMemory': 'Wiedza i pamięć',
'settings.devGroups.agentsAutonomy': 'Agenci i autonomia',
'settings.devGroups.modelsInference': 'Modele i wnioskowanie',
'settings.devGroups.automationIntegrations': 'Automatyzacja i integracje',
'settings.devGroups.toolsCapabilities': 'Narzędzia i możliwości',
'settings.devGroups.council': 'Rada',
'settings.analysisViews.title': 'Widoki analizy',
'settings.analysisViews.menuDesc':
'Analiza grafu pamięci — diagram, centralność, spójność, powiązania, świeżość, oś czasu, ścieżki i przestrzenie nazw',
'settings.buildInfo.title': 'Informacje o kompilacji/wersji',
'settings.buildInfo.menuDesc': 'Kompilacja aplikacji, wersja i szczegóły połączenia z rdzeniem',
'settings.dataSync.title': 'Synchronizacja danych',
'settings.dataSync.menuDesc': 'Co synchronizuje Twój asystent — źródła, świeżość i status',
'settings.dataSync.description':
'Zarządzaj tym, co jest synchronizowane do pamięci asystenta: każde połączone źródło z czasem ostatniej synchronizacji, ile zsynchronizowano i czy synchronizacja trwa teraz.',
'settings.devGroups.diagnosticsLogs': 'Diagnostyka i logi',
'settings.featuresAndAI': 'Funkcje i AI',
'settings.billingAndRewards': 'Rozliczenia i nagrody',
'settings.support': 'Wsparcie',
@@ -87,6 +144,13 @@ const messages: TranslationMap = {
'settings.developerOptions': 'Zaawansowane',
'settings.developerOptionsDesc':
'Konfiguracja AI, kanały wiadomości, narzędzia, diagnostyka i panele debugowania',
'settings.developerDiagnostics': 'Deweloper i Diagnostyka',
'settings.developerDiagnosticsDesc':
'Zaawansowane narzędzia deweloperskie, diagnostyka, pamięć, agenci i panele debugowania',
'settings.developerMode.title': 'Tryb deweloperski',
'settings.developerMode.description':
'Pokaż zaawansowane narzędzia deweloperskie i diagnostyczne',
'settings.developerMode.enabledByBuild': 'Zawsze włączony w buildach deweloperskich',
'settings.clearAppData': 'Wyczyść dane aplikacji',
'settings.clearAppDataDesc': 'Wyloguj się i trwale wyczyść wszystkie lokalne dane aplikacji',
'settings.logOut': 'Wyloguj się',
@@ -98,6 +162,14 @@ const messages: TranslationMap = {
'settings.languageDesc': 'Język wyświetlania interfejsu aplikacji',
'settings.alerts': 'Alerty',
'settings.alertsDesc': 'Zobacz ostatnie alerty i aktywność w skrzynce odbiorczej',
'settings.account.profile': 'Profil',
'settings.account.profileDesc': 'Imię, e-mail i awatar',
'settings.account.devices': 'Urządzenia',
'settings.account.devicesDesc': 'Paruj urządzenia mobilne i zarządzaj nimi',
'settings.account.teamMembers': 'Zespół i członkowie',
'settings.account.teamMembersDesc': 'Zarządzaj dostępem do zespołu i rolami członków',
'settings.account.dataMigration': 'Dane i migracja',
'settings.account.dataMigrationDesc': 'Importuj pamięć z innego asystenta',
'settings.account.recoveryPhrase': 'Fraza odzyskiwania',
'settings.account.recoveryPhraseDesc': 'Wyświetl i zabezpiecz swoją frazę odzyskiwania',
'settings.account.team': 'Zespół',
@@ -263,7 +335,7 @@ const messages: TranslationMap = {
'skills.connected': 'Połączone',
'skills.available': 'Dostępne',
'skills.addAccount': 'Dodaj konto',
'skills.channels': 'Kanały',
'skills.channels': 'Wiadomości',
'skills.explorer.emptyCta': 'Zainstaluj z URL',
'skills.explorer.emptyDescription':
'Zainstaluj pakiet SKILL.md albo umieść foldery w stylu Hermes w ~/.openhuman/skills.',
@@ -291,9 +363,9 @@ const messages: TranslationMap = {
'skills.explorer.installed': 'Zainstalowano',
'skills.explorer.install': 'Zainstaluj',
'skills.explorer.installing': 'Instalowanie…',
'skills.integrations': 'Integracje Composio',
'skills.integrations': 'Aplikacje',
'skills.integrationsSubtitle':
'Chmurowe połączenia OAuth — zaloguj się swoim kontem, a Composio pośredniczy w tokenach, dzięki czemu agenci mogą czytać i działać w Twoim imieniu. Bez kluczy API do utrzymywania.',
'Chmurowe połączenia OAuth — zaloguj się swoim kontem, a tokeny są bezpiecznie zarządzane, dzięki czemu agenci mogą czytać i działać w Twoim imieniu. Bez kluczy API do utrzymywania.',
'skills.composio.noApiKeyTitle': 'Brak skonfigurowanego klucza API Composio',
'skills.composio.noApiKeyDescription':
'W trybie lokalnym używany jest Twój własny klucz API Composio. Otwórz Ustawienia → Zaawansowane → Composio, aby dodać klucz, zanim podłączysz tutaj integracje.',
@@ -303,6 +375,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'Skille',
'skills.tabs.meetings': 'Spotkania Google Meet',
'skills.tabs.mcp': 'Serwery MCP',
'connections.tabs.apps': 'Aplikacje',
'connections.tabs.messaging': 'Wiadomości',
'connections.tabs.tools': 'Narzędzia',
'connections.tabs.explorer': 'Eksplorator',
'connections.tabs.talents': 'Talenty',
'memory.title': 'Pamięć',
'memory.search': 'Szukaj w pamięci...',
'memory.noResults': 'Nie znaleziono wspomnień',
@@ -711,6 +788,7 @@ const messages: TranslationMap = {
'team.failedChangeRole': 'Nie udało się zmienić roli',
'team.failedRemoveMember': 'Nie udało się usunąć członka',
'devOptions.title': 'Zaawansowane',
'devOptions.titleDiagnostics': 'Deweloper i Diagnostyka',
'devOptions.diagnostics': 'Diagnostyka',
'devOptions.diagnosticsDesc': 'Zdrowie systemu, logi i metryki wydajności',
'devOptions.toolPolicyDiagnosticsDesc':
@@ -1842,7 +1920,7 @@ const messages: TranslationMap = {
'chat.safetyTimeout':
'Brak odpowiedzi agenta po 2 minutach. Spróbuj ponownie lub sprawdź połączenie.',
'chat.filter.general': 'Ogólne',
'chat.filter.subconscious': 'Podświadomość',
'chat.filter.subconscious': 'Aktywność w tle',
'chat.filter.meetings': 'Spotkania',
'chat.filter.tasks': 'Zadania',
'chat.selectThread': 'Wybierz wątek',
@@ -3899,6 +3977,11 @@ const messages: TranslationMap = {
'Skonfiguruj zewnętrznych klientów MCP do połączenia z OpenHumanem',
'settings.developerMenu.autonomy.title': 'Autonomia agenta',
'settings.developerMenu.autonomy.desc': 'Limity szybkości akcji narzędzi i progi bezpieczeństwa',
'settings.developerMenu.autocomplete.title': 'Autouzupełnianie',
'settings.developerMenu.autocomplete.desc': 'Ustawienia autouzupełniania AI i panel debugowania',
'settings.developerMenu.voiceDebug.title': 'Głos (debugowanie)',
'settings.developerMenu.voiceDebug.desc':
'Status środowiska uruchomieniowego dyktowania głosowego i ustawienia debugowania',
'settings.mcpServer.title': 'Serwer MCP',
'settings.mcpServer.toolsSectionTitle': 'Dostępne narzędzia',
'settings.mcpServer.toolsSectionDesc':
@@ -4000,6 +4083,26 @@ const messages: TranslationMap = {
'settings.agentAccess.approvalHistoryDesc':
'Przegląd wcześniejszych decyzji Zatwierdź / Odrzuć żądanych przez agenta.',
'settings.agentAccess.viewApprovalHistory': 'Wyświetl historię zatwierdzeń',
// ── Panel Uprawnień ───────────────────────────────────────────────
'settings.permissions.title': 'Uprawnienia',
'settings.permissions.menuDesc': 'Wybierz, co może robić Twój asystent i gdzie może pracować.',
'settings.permissions.accessMode': 'Co może robić asystent?',
'settings.permissions.accessModeDesc':
'Wybierz, ile swobody ma asystent podczas wykonywania działań na Twoim komputerze.',
'settings.permissions.preset.readonly.title': 'Patrzeć, nie dotykać',
'settings.permissions.preset.readonly.desc':
'Asystent może czytać pliki i eksplorować — ale nigdy nie pisze, edytuje ani nie uruchamia niczego zmieniającego stan.',
'settings.permissions.preset.supervised.title': 'Najpierw zapytaj',
'settings.permissions.preset.supervised.desc':
'Może swobodnie tworzyć nowe pliki, ale zawsze prosi o Twoją zgodę przed edycją, uruchamianiem poleceń lub dostępem do sieci.',
'settings.permissions.preset.full.title': 'Pełna kontrola',
'settings.permissions.preset.full.desc':
'Działa z pełnym dostępem do Twojego konta. Destrukcyjne polecenia, dostęp do sieci i instalacje nadal wymagają zatwierdzenia.',
'settings.permissions.folders': 'Gdzie może pracować?',
'settings.permissions.foldersDesc':
'Domyślny folder, który asystent odczytuje i zapisuje. Możesz dodać więcej folderów w ustawieniach zaawansowanych.',
'settings.sandbox.title': 'Wykonanie w piaskownicy',
'settings.sandbox.menuDesc': 'Konfiguruj backendy piaskownicy do izolacji narzędzi agenta.',
'settings.sandbox.loading': 'Ładowanie…',
@@ -4220,7 +4323,7 @@ const messages: TranslationMap = {
'skills.channelIcon.telegram': 'Telegram',
'skills.channelIcon.web': 'Sieć',
'skills.channelIcon.yuanbao': 'Yuanbao',
'skills.composio.poweredBy': 'Napędzane przez Composio',
'skills.composio.poweredBy': 'OAuth',
'skills.composio.staleStatusTitle': 'Połączenia pokazują nieaktualny status',
'skills.create.allowedTools': 'Dozwolone narzędzia',
'skills.create.allowedToolsHelp': 'Renderowane do nagłówka frontmatter SKILL.md jako',
@@ -4827,6 +4930,12 @@ const messages: TranslationMap = {
'autocomplete.maxChars': 'Maksymalna liczba znaków kontekstu',
'autocomplete.overlayTtlMs': 'Limit czasu nakładki (ms)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'Automatyzacje',
'activity.tabs.automationsDescription':
'Wielokrotnego użytku, uruchamialne procedury — cel i kroki do jego osiągnięcia.',
'activity.tabs.backgroundActivity': 'Aktywność w tle',
'activity.tabs.alerts': 'Alerty',
'intelligence.agents.title': 'Biblioteka agentów',
'intelligence.agents.subtitle':
'Przeglądaj uruchamialnych specjalistów i wyślij zadanie do wskazanego agenta.',
+118 -5
View File
@@ -6,6 +6,11 @@ const messages: TranslationMap = {
'nav.home': 'Início',
'nav.human': 'Humano',
'nav.chat': 'Bate-papo',
'nav.assistant': 'Assistente',
'assistant.faceMode.on': 'Falando com o Tiny',
'assistant.faceMode.off': 'Falar com o Tiny',
'assistant.faceMode.turnOn': 'Mostrar mascote',
'assistant.faceMode.turnOff': 'Ocultar mascote',
'nav.connections': 'Conexões',
'nav.memory': 'Inteligência',
'nav.alerts': 'Alertas',
@@ -15,6 +20,12 @@ const messages: TranslationMap = {
'nav.switchAgentProfile': 'Trocar perfil de agente',
'nav.defaultAgentProfile': 'Agente padrão',
'nav.noAgentProfiles': 'Nenhum perfil de agente encontrado',
'nav.activity': 'Atividade',
'nav.avatarMenu.account': 'Conta',
'nav.avatarMenu.billing': 'Faturamento',
'nav.avatarMenu.rewards': 'Recompensas',
'nav.avatarMenu.invites': 'Convidar um amigo',
'nav.avatarMenu.wallet': 'Carteira',
'common.cancel': 'Cancelar',
'common.save': 'Salvar',
'common.confirm': 'Confirmar',
@@ -59,6 +70,54 @@ const messages: TranslationMap = {
'common.comingSoon': 'Em breve',
'common.breadcrumb': 'Breadcrumb',
'settings.general': 'Geral',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Conta',
'settings.groups.assistant': 'Assistente',
'settings.groups.privacySecurity': 'Privacidade e segurança',
'settings.groups.notifications': 'Notificações',
'settings.groups.about': 'Sobre',
'settings.assistant.personality': 'Personalidade',
'settings.assistant.personalityDesc': 'Nome, descrição e persona SOUL.md',
'settings.assistant.voice': 'Voz',
'settings.assistant.voiceDesc': 'Configurações de fala para texto e texto para fala',
'settings.assistant.faceMascot': 'Rosto / Mascote',
'settings.assistant.faceMascotDesc': 'Escolha a cor do mascote usada no aplicativo',
'settings.assistant.backgroundActivity': 'Atividade em segundo plano',
'settings.assistant.backgroundActivityDesc':
'Controle o quão ativamente seu assistente trabalha em segundo plano',
'settings.assistant.screenAwareness': 'Consciência de tela',
'settings.assistant.screenAwarenessDesc': 'Permitir que o assistente veja sua janela ativa',
'settings.assistant.desktopCompanion': 'Companheiro de área de trabalho',
'settings.assistant.desktopCompanionDesc':
'Modo companheiro sempre ativo com atalho na bandeja do sistema',
'settings.assistant.permissions': 'Permissões',
'settings.assistant.permissionsDesc':
'Escolha o que o assistente pode fazer e onde pode trabalhar',
'settings.privacySecurity.privacy': 'Privacidade',
'settings.privacySecurity.privacyDesc': 'Controlar quais dados saem do seu computador',
'settings.privacySecurity.security': 'Segurança',
'settings.privacySecurity.securityDesc': 'Sessões e opções de login',
'settings.privacySecurity.approvalsHistory': 'Aprovações e histórico',
'settings.privacySecurity.approvalsHistoryDesc':
'Revisar decisões recentes de aprovação de ferramentas',
'settings.notifications.menuTitle': 'Notificações',
'settings.notifications.menuDesc': 'Caixa de entrada de alertas e preferências de notificações',
'settings.devGroups.knowledgeMemory': 'Conhecimento e memória',
'settings.devGroups.agentsAutonomy': 'Agentes e autonomia',
'settings.devGroups.modelsInference': 'Modelos e inferência',
'settings.devGroups.automationIntegrations': 'Automação e integrações',
'settings.devGroups.toolsCapabilities': 'Ferramentas e capacidades',
'settings.devGroups.council': 'Conselho',
'settings.analysisViews.title': 'Visões de análise',
'settings.analysisViews.menuDesc':
'Análise do grafo de memória — diagrama, centralidade, coesão, associações, atualidade, linha do tempo, caminhos e namespaces',
'settings.buildInfo.title': 'Informações de build/versão',
'settings.buildInfo.menuDesc': 'Build do app, versão e detalhes de conexão do núcleo',
'settings.dataSync.title': 'Sincronização de dados',
'settings.dataSync.menuDesc': 'O que o seu assistente sincroniza — fontes, atualidade e status',
'settings.dataSync.description':
'Gerencie o que é sincronizado na memória do seu assistente: cada fonte conectada com seu horário da última sincronização, quanto foi sincronizado e se está sincronizando agora.',
'settings.devGroups.diagnosticsLogs': 'Diagnósticos e registros',
'settings.featuresAndAI': 'Recursos e IA',
'settings.billingAndRewards': 'Cobrança e Recompensas',
'settings.support': 'Suporte',
@@ -88,6 +147,13 @@ const messages: TranslationMap = {
'settings.developerOptions': 'Avançado',
'settings.developerOptionsDesc':
'Configuração de IA, canais de mensagens, ferramentas, diagnósticos e painéis de depuração',
'settings.developerDiagnostics': 'Desenvolvedor e Diagnósticos',
'settings.developerDiagnosticsDesc':
'Ferramentas avançadas de desenvolvedor, diagnósticos, memória, agentes e painéis de depuração',
'settings.developerMode.title': 'Modo desenvolvedor',
'settings.developerMode.description':
'Mostrar ferramentas avançadas de desenvolvedor e diagnóstico',
'settings.developerMode.enabledByBuild': 'Sempre ativado em builds de desenvolvimento',
'settings.clearAppData': 'Limpar Dados do App',
'settings.clearAppDataDesc': 'Sair e excluir permanentemente todos os dados locais do app',
'settings.logOut': 'Sair',
@@ -99,6 +165,14 @@ const messages: TranslationMap = {
'settings.languageDesc': 'Idioma de exibição da interface do app',
'settings.alerts': 'Alertas',
'settings.alertsDesc': 'Ver alertas recentes e atividades na sua caixa de entrada',
'settings.account.profile': 'Perfil',
'settings.account.profileDesc': 'Nome, e-mail e avatar',
'settings.account.devices': 'Dispositivos',
'settings.account.devicesDesc': 'Vincular e gerenciar dispositivos móveis',
'settings.account.teamMembers': 'Equipe & membros',
'settings.account.teamMembersDesc': 'Gerenciar acesso à equipe e funções dos membros',
'settings.account.dataMigration': 'Dados & migração',
'settings.account.dataMigrationDesc': 'Importar memória de outro assistente',
'settings.account.recoveryPhrase': 'Frase de Recuperação',
'settings.account.recoveryPhraseDesc': 'Ver e fazer backup da sua frase de recuperação de conta',
'settings.account.team': 'Equipe',
@@ -267,7 +341,7 @@ const messages: TranslationMap = {
'skills.connected': 'Conectado',
'skills.available': 'Disponível',
'skills.addAccount': 'Adicionar Conta',
'skills.channels': 'Canais',
'skills.channels': 'Mensagens',
'skills.explorer.emptyCta': 'Instalar por URL',
'skills.explorer.emptyDescription':
'Instale um pacote SKILL.md ou coloque pastas no estilo Hermes em ~/.openhuman/skills.',
@@ -295,9 +369,9 @@ const messages: TranslationMap = {
'skills.explorer.installed': 'Instalada',
'skills.explorer.install': 'Instalar',
'skills.explorer.installing': 'Instalando…',
'skills.integrations': 'Integrações',
'skills.integrations': 'Aplicativos',
'skills.integrationsSubtitle':
'Conexões OAuth baseadas em nuvem — faça login com sua conta e o Composio gerencia os tokens para que os agentes possam ler e agir em seu nome. Sem chaves de API para gerenciar.',
'Conexões OAuth baseadas em nuvem — faça login com sua conta e os tokens são gerenciados com segurança para que os agentes possam ler e agir em seu nome. Sem chaves de API para gerenciar.',
'skills.composio.noApiKeyTitle': 'Nenhuma chave de API do Composio configurada',
'skills.composio.noApiKeyDescription':
'O modo local usa sua própria chave de API do Composio. Abra Configurações → Avançado → Composio para adicionar uma antes de conectar integrações aqui.',
@@ -307,6 +381,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'Skills',
'skills.tabs.meetings': 'Reuniões do Google Meet',
'skills.tabs.mcp': 'MCP Servidores',
'connections.tabs.apps': 'Aplicativos',
'connections.tabs.messaging': 'Mensagens',
'connections.tabs.tools': 'Ferramentas',
'connections.tabs.explorer': 'Explorar',
'connections.tabs.talents': 'Talentos',
'memory.title': 'Memória',
'memory.search': 'Pesquisar memórias...',
'memory.noResults': 'Nenhuma memória encontrada',
@@ -717,6 +796,7 @@ const messages: TranslationMap = {
'team.failedChangeRole': 'Falha ao alterar função',
'team.failedRemoveMember': 'Falha ao remover membro',
'devOptions.title': 'Avançado',
'devOptions.titleDiagnostics': 'Desenvolvedor e Diagnósticos',
'devOptions.diagnostics': 'Diagnósticos',
'devOptions.diagnosticsDesc': 'Saúde do sistema, logs e métricas de desempenho',
'devOptions.toolPolicyDiagnosticsDesc':
@@ -1859,7 +1939,7 @@ const messages: TranslationMap = {
'chat.safetyTimeout':
'Nenhuma resposta do agente após 2 minutos. Tente novamente ou verifique sua conexão.',
'chat.filter.general': 'Geral',
'chat.filter.subconscious': 'Subconsciente',
'chat.filter.subconscious': 'Atividade em segundo plano',
'chat.filter.meetings': 'Reuniões',
'chat.filter.tasks': 'Tarefas',
'chat.selectThread': 'Selecione uma conversa',
@@ -3899,6 +3979,12 @@ const messages: TranslationMap = {
'settings.developerMenu.autonomy.title': 'Autonomia do agente',
'settings.developerMenu.autonomy.desc':
'Limites de taxa de ações de ferramentas e limites de segurança',
'settings.developerMenu.autocomplete.title': 'Preenchimento automático',
'settings.developerMenu.autocomplete.desc':
'Configurações de preenchimento automático de IA embutido e painel de depuração',
'settings.developerMenu.voiceDebug.title': 'Voz (depuração)',
'settings.developerMenu.voiceDebug.desc':
'Status de tempo de execução do ditado de voz e configurações de depuração',
'settings.mcpServer.title': 'Servidor MCP',
'settings.mcpServer.toolsSectionTitle': 'Ferramentas disponíveis',
'settings.mcpServer.toolsSectionDesc':
@@ -4001,6 +4087,27 @@ const messages: TranslationMap = {
'settings.agentAccess.approvalHistoryDesc':
'Revise as decisões anteriores de Aprovar / Negar solicitadas pelo agente.',
'settings.agentAccess.viewApprovalHistory': 'Ver histórico de aprovações',
// ── Painel de Permissões ──────────────────────────────────────────
'settings.permissions.title': 'Permissões',
'settings.permissions.menuDesc':
'Escolha o que o seu assistente pode fazer e onde pode trabalhar.',
'settings.permissions.accessMode': 'O que o assistente pode fazer?',
'settings.permissions.accessModeDesc':
'Escolha quanta liberdade o assistente tem ao realizar ações no seu computador.',
'settings.permissions.preset.readonly.title': 'Olhar, não tocar',
'settings.permissions.preset.readonly.desc':
'O assistente pode ler ficheiros e explorar — mas nunca escrever, editar ou executar qualquer coisa que mude o estado.',
'settings.permissions.preset.supervised.title': 'Perguntar primeiro',
'settings.permissions.preset.supervised.desc':
'Pode criar novos ficheiros livremente, mas pede sempre a sua aprovação antes de editar, executar comandos ou aceder à rede.',
'settings.permissions.preset.full.title': 'Controlo total',
'settings.permissions.preset.full.desc':
'Opera com o seu acesso completo à conta. Comandos destrutivos, acesso à rede e instalações ainda pedem aprovação.',
'settings.permissions.folders': 'Onde pode trabalhar?',
'settings.permissions.foldersDesc':
'A pasta predefinida que o assistente lê e escreve. Pode adicionar mais pastas nas definições Avançadas.',
'settings.sandbox.title': 'Execução em sandbox',
'settings.sandbox.menuDesc':
'Configure backends de sandbox para isolamento das ferramentas do agente.',
@@ -4224,7 +4331,7 @@ const messages: TranslationMap = {
'skills.channelIcon.telegram': 'Telegram',
'skills.channelIcon.web': 'Web',
'skills.channelIcon.yuanbao': 'Yuanbao',
'skills.composio.poweredBy': 'Desenvolvido por Composio',
'skills.composio.poweredBy': 'OAuth',
'skills.composio.staleStatusTitle': 'As conexões estão mostrando status obsoleto',
'skills.create.allowedTools': 'Ferramentas permitidas',
'skills.create.allowedToolsHelp': 'Renderizado no frontmatter SKILL.md como',
@@ -4861,6 +4968,12 @@ const messages: TranslationMap = {
'autocomplete.maxChars': 'Máximo de caracteres de contexto',
'autocomplete.overlayTtlMs': 'Tempo limite da sobreposição (ms)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'Automações',
'activity.tabs.automationsDescription':
'Procedimentos reutilizáveis e executáveis — um objetivo e os passos para alcançá-lo.',
'activity.tabs.backgroundActivity': 'Atividade em segundo plano',
'activity.tabs.alerts': 'Alertas',
'intelligence.agents.title': 'Biblioteca de agentes',
'intelligence.agents.subtitle':
'Inspecione especialistas executáveis e envie uma tarefa para um agente nomeado.',
+118 -5
View File
@@ -6,6 +6,11 @@ const messages: TranslationMap = {
'nav.home': 'Главная',
'nav.human': 'Человек',
'nav.chat': 'Чат',
'nav.assistant': 'Ассистент',
'assistant.faceMode.on': 'Говорит с Tiny',
'assistant.faceMode.off': 'Поговорить с Tiny',
'assistant.faceMode.turnOn': 'Показать маскота',
'assistant.faceMode.turnOff': 'Скрыть маскота',
'nav.connections': 'Подключения',
'nav.memory': 'Интеллект',
'nav.alerts': 'Оповещения',
@@ -15,6 +20,12 @@ const messages: TranslationMap = {
'nav.switchAgentProfile': 'Сменить профиль агента',
'nav.defaultAgentProfile': 'Агент по умолчанию',
'nav.noAgentProfiles': 'Профили агентов не найдены',
'nav.activity': 'Активность',
'nav.avatarMenu.account': 'Аккаунт',
'nav.avatarMenu.billing': 'Оплата',
'nav.avatarMenu.rewards': 'Награды',
'nav.avatarMenu.invites': 'Пригласить друга',
'nav.avatarMenu.wallet': 'Кошелёк',
'common.cancel': 'Отмена',
'common.save': 'Сохранить',
'common.confirm': 'Подтвердить',
@@ -59,6 +70,54 @@ const messages: TranslationMap = {
'common.comingSoon': 'Скоро',
'common.breadcrumb': 'Breadcrumb',
'settings.general': 'Общие',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': 'Аккаунт',
'settings.groups.assistant': 'Ассистент',
'settings.groups.privacySecurity': 'Конфиденциальность и безопасность',
'settings.groups.notifications': 'Уведомления',
'settings.groups.about': 'О приложении',
'settings.assistant.personality': 'Личность',
'settings.assistant.personalityDesc': 'Имя, описание и персона SOUL.md',
'settings.assistant.voice': 'Голос',
'settings.assistant.voiceDesc': 'Настройки распознавания и синтеза речи',
'settings.assistant.faceMascot': 'Лицо / Маскот',
'settings.assistant.faceMascotDesc': 'Выберите цвет маскота в приложении',
'settings.assistant.backgroundActivity': 'Фоновая активность',
'settings.assistant.backgroundActivityDesc':
'Управление тем, насколько активно ассистент работает в фоне',
'settings.assistant.screenAwareness': 'Осведомлённость об экране',
'settings.assistant.screenAwarenessDesc': 'Разрешить ассистенту видеть активное окно',
'settings.assistant.desktopCompanion': 'Компаньон рабочего стола',
'settings.assistant.desktopCompanionDesc':
'Режим постоянного компаньона с ярлыком в системном лотке',
'settings.assistant.permissions': 'Разрешения',
'settings.assistant.permissionsDesc':
'Выберите, что может делать помощник и где он может работать',
'settings.privacySecurity.privacy': 'Конфиденциальность',
'settings.privacySecurity.privacyDesc': 'Контроль данных, покидающих ваш компьютер',
'settings.privacySecurity.security': 'Безопасность',
'settings.privacySecurity.securityDesc': 'Сеансы и параметры входа',
'settings.privacySecurity.approvalsHistory': 'Подтверждения и история',
'settings.privacySecurity.approvalsHistoryDesc':
'Просмотр недавних решений об одобрении инструментов',
'settings.notifications.menuTitle': 'Уведомления',
'settings.notifications.menuDesc': 'Входящие оповещения и настройки уведомлений',
'settings.devGroups.knowledgeMemory': 'Знания и память',
'settings.devGroups.agentsAutonomy': 'Агенты и автономия',
'settings.devGroups.modelsInference': 'Модели и вывод',
'settings.devGroups.automationIntegrations': 'Автоматизация и интеграции',
'settings.devGroups.toolsCapabilities': 'Инструменты и возможности',
'settings.devGroups.council': 'Совет',
'settings.analysisViews.title': 'Аналитические виды',
'settings.analysisViews.menuDesc':
'Анализ графа памяти — диаграмма, центральность, связность, ассоциации, свежесть, временная шкала, пути и пространства имён',
'settings.buildInfo.title': 'Сведения о сборке/версии',
'settings.buildInfo.menuDesc': 'Сборка приложения, версия и сведения о подключении ядра',
'settings.dataSync.title': 'Синхронизация данных',
'settings.dataSync.menuDesc': 'Что синхронизирует ваш ассистент — источники, свежесть и статус',
'settings.dataSync.description':
'Управляйте тем, что синхронизируется в память ассистента: каждый подключённый источник с временем последней синхронизации, объёмом синхронизированных данных и тем, идёт ли синхронизация сейчас.',
'settings.devGroups.diagnosticsLogs': 'Диагностика и логи',
'settings.featuresAndAI': 'Функции и AI',
'settings.billingAndRewards': 'Оплата и награды',
'settings.support': 'Поддержка',
@@ -88,6 +147,13 @@ const messages: TranslationMap = {
'settings.developerOptions': 'Дополнительно',
'settings.developerOptionsDesc':
'Настройки AI, каналы связи, инструменты, диагностика и панели отладки',
'settings.developerDiagnostics': 'Разработчик и Диагностика',
'settings.developerDiagnosticsDesc':
'Расширенные инструменты разработчика, диагностика, память, агенты и панели отладки',
'settings.developerMode.title': 'Режим разработчика',
'settings.developerMode.description':
'Показывать расширенные инструменты разработчика и диагностики',
'settings.developerMode.enabledByBuild': 'Всегда включён в сборках разработки',
'settings.clearAppData': 'Очистить данные приложения',
'settings.clearAppDataDesc': 'Выйти из аккаунта и удалить все локальные данные приложения',
'settings.logOut': 'Выйти',
@@ -99,6 +165,14 @@ const messages: TranslationMap = {
'settings.languageDesc': 'Язык отображения интерфейса',
'settings.alerts': 'Оповещения',
'settings.alertsDesc': 'Смотри последние оповещения и активность во входящих',
'settings.account.profile': 'Профиль',
'settings.account.profileDesc': 'Имя, email и аватар',
'settings.account.devices': 'Устройства',
'settings.account.devicesDesc': 'Сопрягайте мобильные устройства и управляйте ими',
'settings.account.teamMembers': 'Команда и участники',
'settings.account.teamMembersDesc': 'Управляйте доступом команды и ролями участников',
'settings.account.dataMigration': 'Данные и миграция',
'settings.account.dataMigrationDesc': 'Импорт памяти из другого ассистента',
'settings.account.recoveryPhrase': 'Фраза восстановления',
'settings.account.recoveryPhraseDesc': 'Просмотр и резервное копирование фразы восстановления',
'settings.account.team': 'Команда',
@@ -261,7 +335,7 @@ const messages: TranslationMap = {
'skills.connected': 'Подключено',
'skills.available': 'Доступно',
'skills.addAccount': 'Добавить аккаунт',
'skills.channels': 'Каналы',
'skills.channels': 'Сообщения',
'skills.explorer.emptyCta': 'Установить по URL',
'skills.explorer.emptyDescription':
'Установите пакет SKILL.md или поместите папки в стиле Hermes в ~/.openhuman/skills.',
@@ -289,9 +363,9 @@ const messages: TranslationMap = {
'skills.explorer.installed': 'Установлено',
'skills.explorer.install': 'Установить',
'skills.explorer.installing': 'Установка…',
'skills.integrations': 'Интеграции',
'skills.integrations': 'Приложения',
'skills.integrationsSubtitle':
'Облачные OAuth-подключения — войдите в свой аккаунт, и Composio управляет токенами, чтобы агенты могли читать и действовать от вашего имени. Никаких API-ключей для управления.',
'Облачные OAuth-подключения — войдите в свой аккаунт, и токены управляются безопасно, чтобы агенты могли читать и действовать от вашего имени. Никаких API-ключей для управления.',
'skills.composio.noApiKeyTitle': 'Ключ API Composio не настроен',
'skills.composio.noApiKeyDescription':
'Локальный режим использует ваш собственный ключ API Composio. Откройте Настройки → Дополнительно → Composio, чтобы добавить ключ перед подключением интеграций здесь.',
@@ -301,6 +375,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': 'Навыки',
'skills.tabs.meetings': 'Встречи Google Meet',
'skills.tabs.mcp': 'MCP Серверы',
'connections.tabs.apps': 'Приложения',
'connections.tabs.messaging': 'Сообщения',
'connections.tabs.tools': 'Инструменты',
'connections.tabs.explorer': 'Обозреватель',
'connections.tabs.talents': 'Таланты',
'memory.title': 'Память',
'memory.search': 'Поиск воспоминаний...',
'memory.noResults': 'Воспоминания не найдены',
@@ -705,6 +784,7 @@ const messages: TranslationMap = {
'team.failedChangeRole': 'Не удалось изменить роль.',
'team.failedRemoveMember': 'Не удалось удалить участника.',
'devOptions.title': 'Дополнительно',
'devOptions.titleDiagnostics': 'Разработчик и Диагностика',
'devOptions.diagnostics': 'Диагностика',
'devOptions.diagnosticsDesc': 'Состояние системы, логи и метрики производительности',
'devOptions.toolPolicyDiagnosticsDesc':
@@ -1835,7 +1915,7 @@ const messages: TranslationMap = {
'chat.safetyTimeout':
'Агент не ответил в течение 2 минут. Попробуй снова или проверь соединение.',
'chat.filter.general': 'Общее',
'chat.filter.subconscious': 'Подсознание',
'chat.filter.subconscious': 'Фоновая активность',
'chat.filter.meetings': 'Встречи',
'chat.filter.tasks': 'Задачи',
'chat.selectThread': 'Выбери чат',
@@ -3868,6 +3948,12 @@ const messages: TranslationMap = {
'settings.developerMenu.autonomy.title': 'Автономия агента',
'settings.developerMenu.autonomy.desc':
'Ограничения частоты действий инструментов и пороги безопасности',
'settings.developerMenu.autocomplete.title': 'Автодополнение',
'settings.developerMenu.autocomplete.desc':
'Настройки встроенного автодополнения ИИ и панель отладки',
'settings.developerMenu.voiceDebug.title': 'Голос (отладка)',
'settings.developerMenu.voiceDebug.desc':
'Состояние среды выполнения голосового диктовки и настройки отладки',
'settings.mcpServer.title': 'MCP',
'settings.mcpServer.toolsSectionTitle': 'Доступные инструменты',
'settings.mcpServer.toolsSectionDesc':
@@ -3969,6 +4055,27 @@ const messages: TranslationMap = {
'settings.agentAccess.approvalHistoryDesc':
'Просмотр прошлых решений Одобрить / Отклонить, запрошенных агентом.',
'settings.agentAccess.viewApprovalHistory': 'Просмотреть историю утверждений',
// ── Панель разрешений ─────────────────────────────────────────────
'settings.permissions.title': 'Разрешения',
'settings.permissions.menuDesc':
'Выберите, что может делать ваш помощник и где он может работать.',
'settings.permissions.accessMode': 'Что может делать помощник?',
'settings.permissions.accessModeDesc':
'Выберите, насколько свободен помощник при выполнении действий на вашем компьютере.',
'settings.permissions.preset.readonly.title': 'Смотреть, не трогать',
'settings.permissions.preset.readonly.desc':
'Помощник может читать файлы и исследовать систему — но никогда не пишет, не редактирует и не запускает ничего, что изменяет состояние.',
'settings.permissions.preset.supervised.title': 'Сначала спросить',
'settings.permissions.preset.supervised.desc':
'Может свободно создавать новые файлы, но всегда запрашивает ваше одобрение перед редактированием, выполнением команд или доступом к сети.',
'settings.permissions.preset.full.title': 'Полный контроль',
'settings.permissions.preset.full.desc':
'Работает с полным доступом к вашей учётной записи. Деструктивные команды, доступ к сети и установка всё равно требуют одобрения.',
'settings.permissions.folders': 'Где он может работать?',
'settings.permissions.foldersDesc':
'Папка по умолчанию, которую помощник читает и в которую пишет. В расширенных настройках можно добавить другие папки.',
'settings.sandbox.title': 'Выполнение в песочнице',
'settings.sandbox.menuDesc': 'Настройте бэкенды песочницы для изоляции инструментов агента.',
'settings.sandbox.loading': 'Загрузка…',
@@ -4188,7 +4295,7 @@ const messages: TranslationMap = {
'skills.channelIcon.telegram': 'Telegram',
'skills.channelIcon.web': 'Интернет',
'skills.channelIcon.yuanbao': 'Yuanbao',
'skills.composio.poweredBy': 'Работает на Composio',
'skills.composio.poweredBy': 'OAuth',
'skills.composio.staleStatusTitle': 'Соединения показывают устаревший статус',
'skills.create.allowedTools': 'Разрешённые инструменты',
'skills.create.allowedToolsHelp': 'Отображается в SKILL.md как',
@@ -4826,6 +4933,12 @@ const messages: TranslationMap = {
'autocomplete.maxChars': 'Макс. символов контекста',
'autocomplete.overlayTtlMs': 'Тайм-аут наложения (мс)',
'memory.tab.council': 'Council',
'activity.tabs.automations': 'Автоматизации',
'activity.tabs.automationsDescription':
'Многократно используемые, запускаемые процедуры — цель и шаги для её достижения.',
'activity.tabs.backgroundActivity': 'Фоновая активность',
'activity.tabs.alerts': 'Уведомления',
'intelligence.agents.title': 'Библиотека агентов',
'intelligence.agents.subtitle':
'Просматривайте доступных специалистов и отправляйте задачу выбранному агенту.',
+106 -5
View File
@@ -6,6 +6,11 @@ const messages: TranslationMap = {
'nav.home': '首页',
'nav.human': '助手',
'nav.chat': '对话',
'nav.assistant': '助手',
'assistant.faceMode.on': '正在与 Tiny 对话',
'assistant.faceMode.off': '与 Tiny 对话',
'assistant.faceMode.turnOn': '显示形象',
'assistant.faceMode.turnOff': '隐藏形象',
'nav.connections': '连接',
'nav.memory': '记忆',
'nav.alerts': '通知',
@@ -15,6 +20,12 @@ const messages: TranslationMap = {
'nav.switchAgentProfile': '切换代理档案',
'nav.defaultAgentProfile': '默认代理',
'nav.noAgentProfiles': '未找到代理档案',
'nav.activity': '动态',
'nav.avatarMenu.account': '账户',
'nav.avatarMenu.billing': '账单',
'nav.avatarMenu.rewards': '奖励',
'nav.avatarMenu.invites': '邀请好友',
'nav.avatarMenu.wallet': '钱包',
'common.cancel': '取消',
'common.save': '保存',
'common.confirm': '确认',
@@ -59,6 +70,50 @@ const messages: TranslationMap = {
'common.comingSoon': '即将推出',
'common.breadcrumb': '面包屑',
'settings.general': '通用',
// Settings layman groups (Phase 4 IA revamp)
'settings.groups.account': '账户',
'settings.groups.assistant': '助手',
'settings.groups.privacySecurity': '隐私与安全',
'settings.groups.notifications': '通知',
'settings.groups.about': '关于',
'settings.assistant.personality': '个性',
'settings.assistant.personalityDesc': '名称、描述和 SOUL.md 人设',
'settings.assistant.voice': '语音',
'settings.assistant.voiceDesc': '语音转文字和文字转语音设置',
'settings.assistant.faceMascot': '面孔 / 吉祥物',
'settings.assistant.faceMascotDesc': '选择应用中使用的吉祥物颜色',
'settings.assistant.backgroundActivity': '后台活动',
'settings.assistant.backgroundActivityDesc': '控制助手在后台工作的活跃程度',
'settings.assistant.screenAwareness': '屏幕感知',
'settings.assistant.screenAwarenessDesc': '允许助手查看您的活动窗口',
'settings.assistant.desktopCompanion': '桌面伴侣',
'settings.assistant.desktopCompanionDesc': '带有系统托盘快捷方式的常驻伴侣模式',
'settings.assistant.permissions': '权限',
'settings.assistant.permissionsDesc': '选择助手可以做什么以及可以在哪里工作',
'settings.privacySecurity.privacy': '隐私',
'settings.privacySecurity.privacyDesc': '控制哪些数据离开您的计算机',
'settings.privacySecurity.security': '安全',
'settings.privacySecurity.securityDesc': '会话和登录选项',
'settings.privacySecurity.approvalsHistory': '审批与历史',
'settings.privacySecurity.approvalsHistoryDesc': '查看最近的工具审批决定',
'settings.notifications.menuTitle': '通知',
'settings.notifications.menuDesc': '提醒收件箱和通知偏好设置',
'settings.devGroups.knowledgeMemory': '知识与记忆',
'settings.devGroups.agentsAutonomy': '智能体与自主性',
'settings.devGroups.modelsInference': '模型与推理',
'settings.devGroups.automationIntegrations': '自动化与集成',
'settings.devGroups.toolsCapabilities': '工具与能力',
'settings.devGroups.council': '委员会',
'settings.analysisViews.title': '分析视图',
'settings.analysisViews.menuDesc':
'内存图分析 — 关系图、中心性、内聚性、关联、新鲜度、时间线、路径和命名空间',
'settings.buildInfo.title': '构建/版本信息',
'settings.buildInfo.menuDesc': '应用构建、版本和核心连接详情',
'settings.dataSync.title': '数据同步',
'settings.dataSync.menuDesc': '助手同步的内容 — 来源、新鲜度和状态',
'settings.dataSync.description':
'管理同步到助手记忆中的内容:每个已连接的来源及其上次同步时间、已同步的数量,以及当前是否正在同步。',
'settings.devGroups.diagnosticsLogs': '诊断与日志',
'settings.featuresAndAI': '功能与 AI',
'settings.billingAndRewards': '账单与奖励',
'settings.support': '支持',
@@ -86,6 +141,11 @@ const messages: TranslationMap = {
'settings.aboutDesc': '应用版本与软件更新',
'settings.developerOptions': '开发者选项',
'settings.developerOptionsDesc': '诊断、调试面板、Webhook 与记忆检查',
'settings.developerDiagnostics': '开发者与诊断',
'settings.developerDiagnosticsDesc': '高级开发者工具、诊断、记忆、代理和调试面板',
'settings.developerMode.title': '开发者模式',
'settings.developerMode.description': '显示高级开发者和诊断工具',
'settings.developerMode.enabledByBuild': '开发版本中始终启用',
'settings.clearAppData': '清除应用数据',
'settings.clearAppDataDesc': '退出登录并永久清除所有本地应用数据',
'settings.logOut': '退出登录',
@@ -97,6 +157,14 @@ const messages: TranslationMap = {
'settings.languageDesc': '应用界面显示语言',
'settings.alerts': '通知',
'settings.alertsDesc': '查看收件箱中的最新通知和活动',
'settings.account.profile': '个人资料',
'settings.account.profileDesc': '姓名、邮箱和头像',
'settings.account.devices': '设备',
'settings.account.devicesDesc': '配对和管理移动设备',
'settings.account.teamMembers': '团队与成员',
'settings.account.teamMembersDesc': '管理团队访问权限和成员角色',
'settings.account.dataMigration': '数据与迁移',
'settings.account.dataMigrationDesc': '从其他助手导入记忆',
'settings.account.recoveryPhrase': '恢复短语',
'settings.account.recoveryPhraseDesc': '查看并备份你的账户恢复短语',
'settings.account.team': '团队',
@@ -248,7 +316,7 @@ const messages: TranslationMap = {
'skills.connected': '已连接',
'skills.available': '可用',
'skills.addAccount': '添加账户',
'skills.channels': '渠道',
'skills.channels': '消息',
'skills.explorer.emptyCta': '通过 URL 安装',
'skills.explorer.emptyDescription':
'安装 SKILL.md 包,或将 Hermes 风格的文件夹放到 ~/.openhuman/skills 下。',
@@ -274,9 +342,9 @@ const messages: TranslationMap = {
'skills.explorer.installed': '已安装',
'skills.explorer.install': '安装',
'skills.explorer.installing': '安装中…',
'skills.integrations': '集成',
'skills.integrations': '应用',
'skills.integrationsSubtitle':
'基于云端的 OAuth 连接——使用您的账户登录,Composio 代管令牌,让智能体能以您的名义读取数据并执行操作,无需管理 API 密钥。',
'基于云端的 OAuth 连接——使用您的账户登录,令牌将被安全管理,让智能体能以您的名义读取数据并执行操作,无需管理 API 密钥。',
'skills.composio.noApiKeyTitle': '尚未配置 Composio API 密钥',
'skills.composio.noApiKeyDescription':
'本地模式使用你自己的 Composio API 密钥。在此连接集成之前,请打开 设置 → 高级 → Composio 添加一个密钥。',
@@ -286,6 +354,11 @@ const messages: TranslationMap = {
'skills.tabs.explorer': '技能',
'skills.tabs.meetings': 'Google Meet 会议',
'skills.tabs.mcp': 'MCP 服务器',
'connections.tabs.apps': '应用',
'connections.tabs.messaging': '消息',
'connections.tabs.tools': '工具',
'connections.tabs.explorer': '探索',
'connections.tabs.talents': '才能',
'memory.title': '记忆',
'memory.search': '搜索记忆...',
'memory.noResults': '未找到记忆',
@@ -662,6 +735,7 @@ const messages: TranslationMap = {
'team.failedChangeRole': '角色变更失败',
'team.failedRemoveMember': '删除会员失败',
'devOptions.title': '开发者选项',
'devOptions.titleDiagnostics': '开发者与诊断',
'devOptions.diagnostics': '诊断',
'devOptions.diagnosticsDesc': '系统健康、日志与性能指标',
'devOptions.toolPolicyDiagnosticsDesc': '工具清单、策略态势、MCP 允许列表和近期拦截',
@@ -1718,7 +1792,7 @@ const messages: TranslationMap = {
'common.enable': '启用',
'chat.safetyTimeout': '助手 2 分钟内未响应。请重试或检查你的连接。',
'chat.filter.general': '常规',
'chat.filter.subconscious': '潜意识',
'chat.filter.subconscious': '后台活动',
'chat.filter.meetings': '会议',
'chat.filter.tasks': '任务',
'chat.selectThread': '选择一个对话',
@@ -3645,6 +3719,10 @@ const messages: TranslationMap = {
'settings.developerMenu.mcpServer.desc': '配置外部 MCP 客户端以连接到 OpenHuman',
'settings.developerMenu.autonomy.title': '智能体自主权',
'settings.developerMenu.autonomy.desc': '工具操作速率限制和安全阈值',
'settings.developerMenu.autocomplete.title': '自动补全',
'settings.developerMenu.autocomplete.desc': 'AI 内联自动补全设置和调试面板',
'settings.developerMenu.voiceDebug.title': '语音(调试)',
'settings.developerMenu.voiceDebug.desc': '语音听写运行时状态和调试设置',
'settings.mcpServer.title': 'MCP 服务器',
'settings.mcpServer.toolsSectionTitle': '可用工具',
'settings.mcpServer.toolsSectionDesc':
@@ -3741,6 +3819,24 @@ const messages: TranslationMap = {
'settings.agentAccess.approvalHistory': '审批历史',
'settings.agentAccess.approvalHistoryDesc': '查看智能体请求的历次批准/拒绝决定。',
'settings.agentAccess.viewApprovalHistory': '查看审批历史',
// ── 权限面板 ──────────────────────────────────────────────────────
'settings.permissions.title': '权限',
'settings.permissions.menuDesc': '选择您的助手可以做什么以及可以在哪里工作。',
'settings.permissions.accessMode': '助手可以做什么?',
'settings.permissions.accessModeDesc': '选择助手在您的电脑上执行操作时拥有多大的自由度。',
'settings.permissions.preset.readonly.title': '只看不动',
'settings.permissions.preset.readonly.desc':
'助手可以读取文件并进行探索 — 但绝不会写入、编辑或运行任何改变状态的内容。',
'settings.permissions.preset.supervised.title': '先征求意见',
'settings.permissions.preset.supervised.desc':
'可以自由创建新文件,但在编辑、运行命令或访问网络之前,始终会请求您的批准。',
'settings.permissions.preset.full.title': '完全控制',
'settings.permissions.preset.full.desc':
'以您的完整账户权限运行。破坏性命令、网络访问和安装仍需批准。',
'settings.permissions.folders': '它可以在哪里工作?',
'settings.permissions.foldersDesc': '助手读写的默认文件夹。您可以在高级设置中添加更多文件夹。',
'settings.sandbox.title': '沙盒执行',
'settings.sandbox.menuDesc': '配置沙盒后端以隔离代理工具。',
'settings.sandbox.loading': '加载中…',
@@ -3947,7 +4043,7 @@ const messages: TranslationMap = {
'skills.channelIcon.telegram': 'Telegram',
'skills.channelIcon.web': '网络',
'skills.channelIcon.yuanbao': '元宝',
'skills.composio.poweredBy': '由 Composio 提供支持',
'skills.composio.poweredBy': 'OAuth',
'skills.composio.staleStatusTitle': '连接显示陈旧状态',
'skills.create.allowedTools': '允许的工具',
'skills.create.allowedToolsHelp': '渲染到 SKILL.md frontmatter 中为',
@@ -4544,6 +4640,11 @@ const messages: TranslationMap = {
'autocomplete.maxChars': '最大上下文字符数',
'autocomplete.overlayTtlMs': '覆盖层超时 (ms)',
'memory.tab.council': 'Council',
'activity.tabs.automations': '自动化',
'activity.tabs.automationsDescription': '可复用、可运行的流程 — 目标及达成目标的步骤。',
'activity.tabs.backgroundActivity': '后台活动',
'activity.tabs.alerts': '提醒',
'intelligence.agents.title': '智能体库',
'intelligence.agents.subtitle': '查看可运行的专家,并把一个任务交给指定智能体。',
'intelligence.agents.refresh': '刷新',
+2 -2
View File
@@ -78,8 +78,8 @@ describe('resolveSystemRoute', () => {
expect(resolveSystemRoute(makeSystem({ category: 'agents' }))).toBe('/chat');
});
it('routes skills category to /skills', () => {
expect(resolveSystemRoute(makeSystem({ category: 'skills' }))).toBe('/skills');
it('routes skills category to /connections (Phase 2 rename)', () => {
expect(resolveSystemRoute(makeSystem({ category: 'skills' }))).toBe('/connections');
});
it('routes system category to /home', () => {
+2 -2
View File
@@ -11,7 +11,7 @@ const log = debug('notifications:router');
const ROUTES = {
chat: '/chat',
skills: '/skills',
skills: '/connections',
home: '/home',
notifications: '/notifications',
} as const;
@@ -83,7 +83,7 @@ export function resolveSystemRoute(item: NotificationItem): string {
log('[notification-router] system id=%s category=agents → /chat', item.id);
return ROUTES.chat;
case 'skills':
log('[notification-router] system id=%s category=skills → /skills', item.id);
log('[notification-router] system id=%s category=skills → /connections', item.id);
return ROUTES.skills;
case 'system':
log('[notification-router] system id=%s category=system → /home', item.id);
+168 -4
View File
@@ -3,6 +3,13 @@ import { useEffect, useMemo, useState } from 'react';
import AddAccountModal from '../components/accounts/AddAccountModal';
import { AgentIcon, ProviderIcon } from '../components/accounts/providerIcons';
import WebviewHost from '../components/accounts/WebviewHost';
import {
CustomGifMascot,
getMascotPalette,
hexToArgbInt,
RiveMascot,
} from '../features/human/Mascot';
import { useHumanMascot } from '../features/human/useHumanMascot';
import { usePrewarmMostRecentAccount } from '../hooks/usePrewarmMostRecentAccount';
import { useT } from '../lib/i18n/I18nContext';
import { trackEvent } from '../services/analytics';
@@ -19,9 +26,18 @@ import {
setLastActiveAccount,
} from '../store/accountsSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import {
selectCustomMascotGifUrl,
selectCustomPrimaryColor,
selectCustomSecondaryColor,
selectMascotColor,
} from '../store/mascotSlice';
import type { Account, AccountProvider, ProviderDescriptor } from '../types/accounts';
import { AGENT_ACCOUNT_ID as AGENT_ID } from '../utils/accountsFullscreen';
import { AgentChatPanel } from './Conversations';
import Conversations, { AgentChatPanel } from './Conversations';
// Persistence key for face-toggle state across sessions.
const FACE_MODE_KEY = 'chat.faceMode';
function makeAccountId(): string {
const c = globalThis.crypto;
@@ -99,6 +115,83 @@ interface ContextMenuState {
y: number;
}
/**
* Mascot + TTS panel rendered in face mode (right column of the Assistant
* surface). Extracted as a separate component so its hooks only run when
* face mode is on — keeps the main Accounts component lean when the toggle
* is off.
*
* Phase 6 — reuses the exact same mascot subcomponents and useHumanMascot
* hook from features/human/ rather than duplicating any logic.
*/
const FaceModePanel = () => {
const { t } = useT();
const [speakReplies, setSpeakReplies] = useState<boolean>(() => {
try {
const raw = window.localStorage.getItem('human.speakReplies');
return raw === null ? true : raw === '1';
} catch {
return true;
}
});
useEffect(() => {
try {
window.localStorage.setItem('human.speakReplies', speakReplies ? '1' : '0');
} catch {
// localStorage may be unavailable in sandboxed contexts.
}
}, [speakReplies]);
const { face, visemeCode } = useHumanMascot({ speakReplies });
const mascotColor = useAppSelector(selectMascotColor);
const customPrimary = useAppSelector(selectCustomPrimaryColor);
const customSecondary = useAppSelector(selectCustomSecondaryColor);
const customMascotGifUrl = useAppSelector(selectCustomMascotGifUrl);
const palette = getMascotPalette(mascotColor);
const primaryColor = useMemo(
() => hexToArgbInt(mascotColor === 'custom' ? customPrimary : palette.bodyFill),
[mascotColor, customPrimary, palette]
);
const secondaryColor = useMemo(
() => hexToArgbInt(mascotColor === 'custom' ? customSecondary : palette.neckShadowColor),
[mascotColor, customSecondary, palette]
);
return (
<aside
className="flex min-w-0 flex-1 flex-col items-center justify-center gap-4 bg-stone-50 dark:bg-neutral-900/60 rounded-2xl border border-stone-200/70 dark:border-neutral-800/70 my-3 mr-0 py-4 px-3 overflow-hidden"
data-testid="face-mode-panel">
{/* Mascot stage — the dominant element of the "Talk to Tiny" surface */}
<div className="relative w-full max-w-[460px] aspect-square">
{customMascotGifUrl ? (
<CustomGifMascot src={customMascotGifUrl} face={face} />
) : (
<RiveMascot
face={face}
primaryColor={primaryColor}
secondaryColor={secondaryColor}
visemeCode={visemeCode}
/>
)}
</div>
{/* TTS / speak-replies toggle */}
<label className="inline-flex cursor-pointer select-none items-center gap-2 rounded-full border border-stone-300 dark:border-neutral-700 bg-white/80 dark:bg-neutral-900/80 px-3 py-1.5 text-xs text-stone-700 dark:text-neutral-200 shadow-soft backdrop-blur-sm">
<input
type="checkbox"
checked={speakReplies}
onChange={e => setSpeakReplies(e.target.checked)}
className="cursor-pointer"
data-testid="speak-replies-toggle"
/>
{t('voice.pushToTalk')}
</label>
</aside>
);
};
const Accounts = () => {
const { t } = useT();
const dispatch = useAppDispatch();
@@ -109,6 +202,28 @@ const Accounts = () => {
const [addOpen, setAddOpen] = useState(false);
const [ctxMenu, setCtxMenu] = useState<ContextMenuState | null>(null);
// Face-mode toggle — persists across sessions. Face mode only affects the
// agent-chat surface (external webview accounts ignore it).
const [faceMode, setFaceMode] = useState<boolean>(() => {
try {
return window.localStorage.getItem(FACE_MODE_KEY) === '1';
} catch {
return false;
}
});
const toggleFaceMode = () => {
setFaceMode(prev => {
const next = !prev;
try {
window.localStorage.setItem(FACE_MODE_KEY, next ? '1' : '0');
} catch {
// Swallow storage errors.
}
return next;
});
};
useEffect(() => {
startWebviewAccountService();
}, []);
@@ -231,6 +346,7 @@ const Accounts = () => {
return (
<div
// `h-full` makes this page fill the shell's content box, which bypasses
className="relative flex h-full gap-3 overflow-hidden"
data-testid="accounts-page"
data-analytics-id="chat-right-sidebar">
@@ -282,10 +398,58 @@ const Accounts = () => {
</button>
</aside>
{/* Main pane */}
<main className="flex min-w-0 flex-1 flex-col">
{/* Floating "Talk to Tiny" face-mode toggle. Kept out of the layout flow
(absolute) so it never steals vertical space from the chat composer —
the previous in-flow header strip pushed the input below the viewport. */}
{isAgentSelected && (
<button
type="button"
onClick={toggleFaceMode}
data-testid="face-toggle-button"
aria-pressed={faceMode}
className={`absolute right-4 top-4 z-40 inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium shadow-soft backdrop-blur-sm transition-colors ${
faceMode
? 'border-primary-300 bg-primary-50/90 text-primary-700 dark:bg-primary-900/40 dark:text-primary-200'
: 'border-stone-300/80 bg-white/90 text-stone-600 hover:border-primary-300 hover:text-primary-600 dark:border-neutral-700/80 dark:bg-neutral-900/90 dark:text-neutral-300 dark:hover:text-primary-300'
}`}
aria-label={faceMode ? t('assistant.faceMode.turnOff') : t('assistant.faceMode.turnOn')}>
<span aria-hidden="true">🙂</span>
{faceMode ? t('assistant.faceMode.on') : t('assistant.faceMode.off')}
</button>
)}
{/* Main pane
In face mode (agent selected), the layout is a horizontal split:
the chat panel on the left and the mascot panel on the right.
Face mode is ignored when an external webview account is active. */}
<main
className={`flex min-w-0 flex-1 gap-3 ${isAgentSelected && faceMode ? 'flex-row' : 'flex-col'}`}>
{isAgentSelected ? (
<AgentChatPanel />
<>
{/* Agent chat — face mode uses sidebar variant to avoid a second
thread list; normal mode uses the full-page variant (AgentChatPanel). */}
<div
className={`flex min-h-0 min-w-0 flex-col ${faceMode ? 'w-[360px] flex-none' : 'flex-1'}`}>
{faceMode ? (
// Face mode: mascot sidebar chat. The toggle floats on the page
// root (see below) so it never steals height from the composer.
// `min-h-0` lets the inner message list scroll instead of growing
// and pushing the composer off-screen.
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-stone-200/70 dark:border-neutral-800/70 my-3 mr-0">
<Conversations variant="sidebar" />
</div>
) : (
// `min-h-0` is required so the chat's internal message list owns
// the overflow (scrolls) rather than expanding and shoving the
// composer below the viewport on long threads.
<div className="min-h-0 flex-1 overflow-hidden">
<AgentChatPanel />
</div>
)}
</div>
{/* Mascot + TTS panel — only visible in face mode */}
{faceMode && <FaceModePanel />}
</>
) : active ? (
<div className="flex-1 py-3 pr-3">
<WebviewHost accountId={active.id} provider={active.provider} />
+193
View File
@@ -0,0 +1,193 @@
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTab';
import { ToastContainer } from '../components/intelligence/Toast';
import WorkflowsTab from '../components/intelligence/WorkflowsTab';
import PillTabBar from '../components/PillTabBar';
import {
useIntelligenceSocket,
useIntelligenceSocketManager,
} from '../hooks/useIntelligenceSocket';
import { useSubconscious } from '../hooks/useSubconscious';
import { useT } from '../lib/i18n/I18nContext';
import type {
ConfirmationModal as ConfirmationModalType,
ToastNotification,
} from '../types/intelligence';
import Notifications from './Notifications';
// Visible tab IDs for the Activity surface.
// memory, agents, and council have moved to Settings → Developer & Diagnostics
// (routes: /settings/intelligence, /settings/agents).
// Back-compat: ?tab=memory / ?tab=agents / ?tab=council are unknown to the
// visible set and therefore fall back to 'tasks' (see makeIsVisibleTab below).
type ActivityTab = 'tasks' | 'automations' | 'backgroundActivity' | 'alerts';
const ACTIVITY_TABS: ActivityTab[] = ['tasks', 'automations', 'backgroundActivity', 'alerts'];
/**
* Returns a type-guard predicate for the currently visible tabs.
* Unknown values (including old deep-link tabs like ?tab=memory) fall back to
* the default tab rather than erroring.
*/
const isVisibleTab = (tab: string | null | undefined): tab is ActivityTab =>
(ACTIVITY_TABS as string[]).includes(tab ?? '');
export default function Activity() {
const { t } = useT();
// Tab is URL-backed (/activity?tab=…) so navigating away and coming back
// restores the same tab. `replace` so switching tabs doesn't stack history.
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const activeTab: ActivityTab = isVisibleTab(tabParam) ? tabParam : 'tasks';
const setActiveTab = useCallback(
(tab: ActivityTab) => {
setSearchParams(
prev => {
prev.set('tab', tab);
return prev;
},
{ replace: true }
);
},
[setSearchParams]
);
// Subconscious engine data (used by the Background Activity tab).
const {
status: subconsciousEngineStatus,
mode: subconsciousMode,
intervalMinutes: subconsciousInterval,
triggering: subconsciousTriggering,
settingMode: subconsciousSettingMode,
triggerTick,
setMode: setSubconsciousMode,
setIntervalMinutes: setSubconsciousInterval,
} = useSubconscious();
// Socket integration
const socketManager = useIntelligenceSocketManager();
const { isConnected: socketConnected } = useIntelligenceSocket();
// Local state for UI
const [toasts, setToasts] = useState<ToastNotification[]>([]);
const [confirmationModal, setConfirmationModal] = useState<ConfirmationModalType>({
isOpen: false,
title: '',
message: '',
onConfirm: () => {},
onCancel: () => {},
});
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
// Initialize socket connection
useEffect(() => {
if (!socketConnected) {
socketManager.connect();
}
}, [socketConnected, socketManager]);
const tabs: { id: ActivityTab; label: string; description?: string; comingSoon?: boolean }[] = [
{ id: 'tasks', label: t('memory.tab.tasks'), description: t('memory.tab.tasksDescription') },
{
id: 'automations',
label: t('activity.tabs.automations'),
description: t('activity.tabs.automationsDescription'),
},
{ id: 'backgroundActivity', label: t('activity.tabs.backgroundActivity') },
{ id: 'alerts', label: t('activity.tabs.alerts') },
];
const activeTabDef = tabs.find(tab => tab.id === activeTab);
return (
<div className="min-h-full p-4 pt-6">
<div className="max-w-4xl mx-auto space-y-4">
<PillTabBar
items={tabs.map(tab => ({ label: tab.label, value: tab.id }))}
selected={activeTab}
onChange={setActiveTab}
activeClassName="border-primary-600 bg-primary-600 text-white"
renderItem={(item, active) => {
const tab = tabs.find(entry => entry.id === item.value);
return (
<span className="inline-flex items-center gap-1.5">
<span>{item.label}</span>
{tab?.comingSoon && (
<span
className={`rounded-full border px-1.5 py-0.5 text-[10px] ${
active
? 'border-white/30 bg-white/15 text-white'
: 'border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 text-stone-500 dark:text-neutral-400'
}`}>
{t('misc.beta')}
</span>
)}
</span>
);
}}
/>
{/* Alerts tab renders outside the card so Notifications can use its own
full-width layout with multiple sections. */}
{activeTab === 'alerts' ? (
<Notifications />
) : (
<div className="bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800 p-6">
<div>
{/* Header — reflects the active tab so the panel title matches
what's shown below it, rather than a static "Activity". */}
<div className="flex items-center justify-between mb-6">
<div className="min-w-0">
<h1
className="text-xl font-bold text-stone-900 dark:text-neutral-100"
data-walkthrough="intelligence-header">
{activeTabDef?.label ?? t('nav.activity')}
</h1>
{activeTabDef?.description && (
<p className="mt-1 text-sm text-stone-500 dark:text-neutral-400">
{activeTabDef.description}
</p>
)}
</div>
</div>
{/* Tab content */}
{activeTab === 'tasks' && <IntelligenceTasksTab />}
{activeTab === 'automations' && <WorkflowsTab />}
{activeTab === 'backgroundActivity' && (
<IntelligenceSubconsciousTab
status={subconsciousEngineStatus}
mode={subconsciousMode}
intervalMinutes={subconsciousInterval}
triggerTick={triggerTick}
triggering={subconsciousTriggering}
settingMode={subconsciousSettingMode}
setMode={setSubconsciousMode}
setIntervalMinutes={setSubconsciousInterval}
/>
)}
</div>
</div>
)}
</div>
{/* Toast notifications */}
<ToastContainer notifications={toasts} onRemove={removeToast} />
{/* Confirmation modal */}
<ConfirmationModal
modal={confirmationModal}
onClose={() => setConfirmationModal(prev => ({ ...prev, isOpen: false }))}
/>
</div>
);
}
+4 -23
View File
@@ -16,29 +16,10 @@ import { selectBlockingState } from '../store/connectivitySelectors';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { resolveTheme, setThemeMode, type ThemeMode } from '../store/themeSlice';
import { APP_VERSION } from '../utils/config';
import { resolveUserName } from '../utils/userName';
export function resolveHomeUserName(user: unknown): string {
if (!user || typeof user !== 'object') return 'User';
const record = user as Record<string, unknown>;
const firstName =
(typeof record.firstName === 'string' && record.firstName.trim()) ||
(typeof record.first_name === 'string' && record.first_name.trim()) ||
'';
const lastName =
(typeof record.lastName === 'string' && record.lastName.trim()) ||
(typeof record.last_name === 'string' && record.last_name.trim()) ||
'';
const username = typeof record.username === 'string' ? record.username.trim() : '';
const email = typeof record.email === 'string' ? record.email.trim() : '';
const fullName = [firstName, lastName].filter(Boolean).join(' ').trim();
if (fullName) return fullName;
if (firstName) return firstName;
if (username) return username.startsWith('@') ? username : `@${username}`;
if (email) return email.split('@')[0] || 'User';
return 'User';
}
/** @deprecated Use `resolveUserName` from `utils/userName`. Kept for back-compat. */
export const resolveHomeUserName = resolveUserName;
const Home = () => {
const { t } = useT();
@@ -279,7 +260,7 @@ const Home = () => {
<div className="text-[11px] uppercase tracking-wide text-stone-400 mb-2">Next steps</div>
<div className="divide-y divide-stone-100">
<button
onClick={() => navigate('/skills')}
onClick={() => navigate('/connections')}
className="w-full flex items-center justify-between py-2.5 text-left hover:bg-stone-50 rounded-md px-2 -mx-2 transition-colors">
<div>
<div className="text-sm font-medium text-stone-900">Connect your services</div>
+5 -1
View File
@@ -6,7 +6,8 @@ import Intelligence from './Intelligence';
vi.mock('../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
// IS_DEV gates the dev-only "council" tab; default to a non-dev build.
// IS_DEV / useDeveloperMode gate the dev-only "council" tab; default to
// a non-dev build so the gate is closed unless the test overrides it.
const isDev = vi.hoisted(() => ({ value: false }));
vi.mock('../utils/config', async () => {
const actual = await vi.importActual<typeof import('../utils/config')>('../utils/config');
@@ -17,6 +18,9 @@ vi.mock('../utils/config', async () => {
},
};
});
// useDeveloperMode combines IS_DEV with the persisted Redux preference.
// Mock it here so tests don't need a Redux Provider — just respect isDev.value.
vi.mock('../hooks/useDeveloperMode', () => ({ useDeveloperMode: () => isDev.value }));
// Heavy hooks → minimal stubs.
vi.mock('../hooks/useIntelligenceSocket', () => ({
+12 -8
View File
@@ -11,6 +11,7 @@ import ModelCouncilTab from '../components/intelligence/ModelCouncilTab';
import { ToastContainer } from '../components/intelligence/Toast';
import WorkflowsTab from '../components/intelligence/WorkflowsTab';
import PillTabBar from '../components/PillTabBar';
import { useDeveloperMode } from '../hooks/useDeveloperMode';
import {
useIntelligenceSocket,
useIntelligenceSocketManager,
@@ -21,7 +22,6 @@ import type {
ConfirmationModal as ConfirmationModalType,
ToastNotification,
} from '../types/intelligence';
import { IS_DEV } from '../utils/config';
type IntelligenceTab =
| 'memory'
@@ -42,17 +42,21 @@ const INTELLIGENCE_TABS: IntelligenceTab[] = [
'council',
];
// Tabs gated to dev builds (mirrors the `devOnly` flags on `allTabs` below).
// A `?tab=` deep link must be validated against the *visible* set, not the raw
// enum, so `?tab=council` can't force-open a hidden dev-only tab in prod.
// Tabs gated to dev builds or runtime developer mode (mirrors the `devOnly`
// flags on `allTabs` below). A `?tab=` deep link must be validated against the
// *visible* set, not the raw enum, so a user cannot force-open a hidden tab.
const DEV_ONLY_TABS: IntelligenceTab[] = ['council'];
const isVisibleTab = (tab: string | null | undefined): tab is IntelligenceTab =>
(INTELLIGENCE_TABS as string[]).includes(tab ?? '') &&
(IS_DEV || !(DEV_ONLY_TABS as string[]).includes(tab ?? ''));
const makeIsVisibleTab =
(developerModeEnabled: boolean) =>
(tab: string | null | undefined): tab is IntelligenceTab =>
(INTELLIGENCE_TABS as string[]).includes(tab ?? '') &&
(developerModeEnabled || !(DEV_ONLY_TABS as string[]).includes(tab ?? ''));
export default function Intelligence() {
const { t } = useT();
const developerMode = useDeveloperMode();
const isVisibleTab = makeIsVisibleTab(developerMode);
// Tab is URL-backed (`/intelligence?tab=…`) so navigating away — e.g. to
// Settings → Task Sources from the Agent Tasks tab — and coming back via
@@ -152,7 +156,7 @@ export default function Intelligence() {
{ id: 'council', label: t('memory.tab.council'), devOnly: true },
{ id: 'agents', label: t('memory.tab.agents'), description: t('memory.tab.agentsDescription') },
];
const tabs = allTabs.filter(tab => !tab.devOnly || IS_DEV);
const tabs = allTabs.filter(tab => !tab.devOnly || developerMode);
const activeTabDef = tabs.find(tab => tab.id === activeTab);
return (
+17 -18
View File
@@ -10,6 +10,7 @@ import AgentChatPanel from '../components/settings/panels/AgentChatPanel';
import AgentEditorPage from '../components/settings/panels/AgentEditorPage';
import AgentsPanel from '../components/settings/panels/AgentsPanel';
import AIPanel from '../components/settings/panels/AIPanel';
import AnalysisViewsPanel from '../components/settings/panels/AnalysisViewsPanel';
import AppearancePanel from '../components/settings/panels/AppearancePanel';
import ApprovalHistoryPanel from '../components/settings/panels/ApprovalHistoryPanel';
import AutocompleteDebugPanel from '../components/settings/panels/AutocompleteDebugPanel';
@@ -32,9 +33,11 @@ import MascotPanel from '../components/settings/panels/MascotPanel';
import McpServerPanel from '../components/settings/panels/McpServerPanel';
import MemoryDataPanel from '../components/settings/panels/MemoryDataPanel';
import MemoryDebugPanel from '../components/settings/panels/MemoryDebugPanel';
import MemorySyncPanel from '../components/settings/panels/MemorySyncPanel';
import MigrationPanel from '../components/settings/panels/MigrationPanel';
import ModelHealthPanel from '../components/settings/panels/ModelHealthPanel';
import NotificationsTabbedPanel from '../components/settings/panels/NotificationsTabbedPanel';
import PermissionsPanel from '../components/settings/panels/PermissionsPanel';
import PersonaPanel from '../components/settings/panels/PersonaPanel';
import PrivacyPanel from '../components/settings/panels/PrivacyPanel';
import RecoveryPhrasePanel from '../components/settings/panels/RecoveryPhrasePanel';
@@ -123,16 +126,6 @@ const ScreenIcon = (
/>
</svg>
);
const MessagingIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 10h.01M12 10h.01M16 10h.01M21 11c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 19l1.395-3.72C3.512 14.042 3 12.574 3 11c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
);
const NotificationsIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -235,7 +228,9 @@ const WalletIcon = (
const WrappedSettingsPage = ({
children,
maxWidthClass = 'max-w-lg',
// Default widened ~30% (max-w-lg 512px → max-w-2xl 672px) for a roomier
// settings list per design feedback.
maxWidthClass = 'max-w-2xl',
}: {
children: ReactNode;
maxWidthClass?: string;
@@ -341,13 +336,8 @@ const Settings = () => {
icon: ScreenIcon,
},
// Autocomplete + Voice Dictation hidden per #717 (routes retained for re-enable).
{
id: 'messaging',
title: t('pages.settings.features.messagingChannels'),
description: t('pages.settings.features.messagingChannelsDesc'),
route: 'messaging',
icon: MessagingIcon,
},
// Dead "messaging" menu item removed (Phase 2): the route `messaging` never
// existed in Settings — messaging channels live at /connections (Messaging tab).
{
id: 'notifications',
title: t('pages.settings.features.notifications'),
@@ -603,6 +593,7 @@ const Settings = () => {
<Route path="persona" element={wrapSettingsPage(<PersonaPanel />)} />
<Route path="appearance" element={wrapSettingsPage(<AppearancePanel />)} />
<Route path="agent-access" element={wrapSettingsPage(<AgentAccessPanel />)} />
<Route path="permissions" element={wrapSettingsPage(<PermissionsPanel />)} />
<Route path="activity-level" element={wrapSettingsPage(<AgentActivityPanel />)} />
<Route path="sandbox-settings" element={wrapSettingsPage(<SandboxSettingsPanel />)} />
<Route path="approval-history" element={wrapSettingsPage(<ApprovalHistoryPanel />)} />
@@ -659,8 +650,16 @@ const Settings = () => {
path="model-health"
element={wrapSettingsPage(<ModelHealthPanel />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route
path="memory-sync"
element={wrapSettingsPage(<MemorySyncPanel />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route path="memory-data" element={wrapSettingsPage(<MemoryDataPanel />)} />
<Route path="memory-debug" element={wrapSettingsPage(<MemoryDebugPanel />)} />
<Route
path="analysis-views"
element={wrapSettingsPage(<AnalysisViewsPanel />, { maxWidthClass: 'max-w-4xl' })}
/>
<Route path="intelligence" element={<Intelligence />} />
<Route path="webhooks-triggers" element={<Webhooks />} />
<Route path="composio-triggers" element={wrapSettingsPage(<ComposioTriagePanel />)} />
+52 -21
View File
@@ -343,7 +343,19 @@ interface SkillItem {
// ─── Main Skills Page ──────────────────────────────────────────────────────────
type ConnectionsTab = 'channels' | 'composio' | 'mcp' | 'skills' | 'meetings';
/**
* Primary tab values for the Connections page.
*
* Phase 2 rename mapping (old → new):
* composio → apps
* channels → messaging
* mcp → tools (meetings content folded in under Tools)
* skills → explorer (kept secondary)
*
* Back-compat: the old ?tab= values (composio, channels, mcp, meetings) are
* normalised to the new values so existing deep links continue to work.
*/
type ConnectionsTab = 'apps' | 'messaging' | 'tools' | 'explorer' | 'talents';
export default function Skills() {
const { t } = useT();
@@ -351,15 +363,28 @@ export default function Skills() {
const location = useLocation();
const navigate = useNavigate();
const isLocalSession = isLocalSessionToken(getCoreStateSnapshot().snapshot.sessionToken);
// Honour `?tab=<composio|channels|mcp|meetings>` so deep links land on the right
// sub-tab. (The legacy `runners` tab was removed; running a workflow now
// lives on its detail drawer → /skills/run.)
// Honour `?tab=<apps|messaging|tools|explorer>` so deep links land on the
// right sub-tab. Also normalise legacy tab names from the old /skills route
// so that e.g. `/skills?tab=composio` still works after the redirect.
const initialTab: ConnectionsTab = (() => {
const params = new URLSearchParams(location.search);
const t = params.get('tab');
if (t === 'composio' || t === 'channels' || t === 'mcp' || t === 'skills' || t === 'meetings')
return t;
return 'composio';
const raw = params.get('tab');
// New canonical values
if (
raw === 'apps' ||
raw === 'messaging' ||
raw === 'tools' ||
raw === 'explorer' ||
raw === 'talents'
)
return raw;
// Legacy back-compat aliases
if (raw === 'composio') return 'apps';
if (raw === 'channels') return 'messaging';
if (raw === 'mcp') return 'tools';
if (raw === 'meetings') return 'talents';
if (raw === 'skills') return 'explorer';
return 'apps';
})();
const [activeTab, setActiveTab] = useState<ConnectionsTab>(initialTab);
const dispatch = useAppDispatch();
@@ -792,16 +817,16 @@ export default function Skills() {
selected={activeTab}
onChange={setActiveTab}
items={[
{ value: 'composio', label: t('skills.tabs.composio') },
{ value: 'channels', label: t('skills.tabs.channels') },
{ value: 'skills', label: t('skills.tabs.explorer') },
{ value: 'meetings', label: t('skills.tabs.meetings') },
{ value: 'mcp', label: t('skills.tabs.mcp') },
{ value: 'apps', label: t('connections.tabs.apps') },
{ value: 'messaging', label: t('connections.tabs.messaging') },
{ value: 'tools', label: t('connections.tabs.tools') },
{ value: 'explorer', label: t('connections.tabs.explorer') },
{ value: 'talents', label: t('connections.tabs.talents') },
]}
/>
{
<>
{activeTab === 'channels' && channelsGroup && (
{activeTab === 'messaging' && 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
@@ -863,9 +888,7 @@ export default function Skills() {
</div>
)}
{activeTab === 'meetings' && <MeetingBotsCard onToast={addToast} />}
{activeTab === 'composio' && (
{activeTab === 'apps' && (
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 shadow-soft animate-fade-up">
<div className="px-1 pb-3 pt-1">
<div className="flex items-center gap-2">
@@ -941,15 +964,23 @@ export default function Skills() {
</div>
)}
{activeTab === 'composio' && otherGroups.map(group => renderGroup(group))}
{activeTab === 'apps' && otherGroups.map(group => renderGroup(group))}
{activeTab === 'skills' && <SkillsExplorerTab onToast={addToast} />}
{activeTab === 'explorer' && <SkillsExplorerTab onToast={addToast} />}
{activeTab === 'mcp' && (
<div className="animate-fade-up">
{activeTab === 'tools' && (
<div className="space-y-4 animate-fade-up">
{/* MCP Servers */}
<McpServersTab />
</div>
)}
{activeTab === 'talents' && (
<div className="space-y-4 animate-fade-up">
{/* Meeting bots live under Talents (moved out of Tools) */}
<MeetingBotsCard onToast={addToast} />
</div>
)}
</>
}
</div>
+4 -4
View File
@@ -30,7 +30,7 @@ const renderPage = () =>
<MemoryRouter initialEntries={['/workflows/new']}>
<Routes>
<Route path="/workflows/new" element={<WorkflowNew />} />
<Route path="/skills" element={<div data-testid="dashboard-landed">dashboard</div>} />
<Route path="/connections" element={<div data-testid="dashboard-landed">dashboard</div>} />
</Routes>
</MemoryRouter>
);
@@ -49,10 +49,10 @@ describe('WorkflowNew', () => {
expect(screen.getByLabelText(/skills.create.description/i)).toBeInTheDocument();
});
it('cancel button navigates back to /skills', () => {
it('cancel button navigates back to /connections', async () => {
renderPage();
fireEvent.click(screen.getByTestId('skill-new-cancel'));
expect(screen.getByTestId('dashboard-landed')).toBeInTheDocument();
expect(await screen.findByTestId('dashboard-landed')).toBeInTheDocument();
});
it('submit is disabled until both required fields are filled', () => {
@@ -71,7 +71,7 @@ describe('WorkflowNew', () => {
expect(submit).not.toBeDisabled();
});
it('navigates to /skills after a successful submit', async () => {
it('navigates to /connections after a successful submit', async () => {
hoisted.createWorkflow.mockResolvedValue({
id: 'new-skill',
name: 'New Skill',
+10 -9
View File
@@ -8,13 +8,14 @@
* new SKILL.md drafts.
*
* Behaviour on submit:
* - Success → navigate to /skills (dashboard) so the user lands
* somewhere meaningful. We considered /workflows/run?workflow=<new-id>,
* but new skills aren't auto-scheduled and the runner picker
* pre-select only makes sense once the user has filled in inputs.
* Dashboard sends a clearer "skill created, here's what's
* scheduled" signal.
* - Cancel → /skills.
* - Success → navigate to /connections so the user lands somewhere
* meaningful. We considered /workflows/run?workflow=<new-id>, but
* new skills aren't auto-scheduled and the runner picker pre-select
* only makes sense once the user has filled in inputs. The
* Connections page (defaulting to Apps tab) provides a clear "here
* are your connections" signal. Use ?tab=explorer to deep-link to
* the Explorer tab if needed.
* - Cancel → /connections.
*/
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
@@ -42,7 +43,7 @@ export default function WorkflowNew() {
// The dashboard re-fetches the cron list on mount, so any
// schedule the user adds for this new skill will appear there
// automatically — no need to plumb the new id through state.
navigate('/skills?tab=runners');
navigate('/connections');
},
[navigate]
);
@@ -68,7 +69,7 @@ export default function WorkflowNew() {
<button
type="button"
data-testid="skill-new-cancel"
onClick={() => navigate('/skills?tab=runners')}
onClick={() => navigate('/connections')}
disabled={submitting}
className="rounded-lg px-4 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 disabled:opacity-40">
{t('common.cancel')}
@@ -0,0 +1,178 @@
/**
* Tests for the Phase 6 face-mode toggle in the Accounts (Assistant) page.
*
* Verifies:
* - Face toggle button is rendered
* - Face mode is off by default
* - Clicking the toggle shows the face-mode panel (data-testid="face-mode-panel")
* - Clicking the toggle again hides the face-mode panel
* - Face mode state is persisted to localStorage (chat.faceMode)
* - When face mode is on, Conversations is rendered with variant="sidebar"
*/
import { configureStore } from '@reduxjs/toolkit';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import accountsReducer from '../../store/accountsSlice';
import chatRuntimeReducer from '../../store/chatRuntimeSlice';
import mascotReducer from '../../store/mascotSlice';
import threadReducer from '../../store/threadSlice';
// ── Static component import (after mocks are hoisted) ───────────────────────
import Accounts from '../Accounts';
// ── Heavy dependency stubs — all must be declared before the component import ──
// Stub Conversations so it doesn't pull in the full chat stack.
vi.mock('../Conversations', () => ({
default: ({ variant }: { variant?: string }) => (
<div data-testid="conversations-stub" data-variant={variant ?? 'page'} />
),
AgentChatPanel: () => <div data-testid="agent-chat-panel-stub" />,
}));
// Stub webview account components.
vi.mock('../../components/accounts/WebviewHost', () => ({
default: () => <div data-testid="webview-host-stub" />,
}));
vi.mock('../../components/accounts/AddAccountModal', () => ({ default: () => null }));
vi.mock('../../components/accounts/providerIcons', () => ({
AgentIcon: ({ className }: { className?: string }) => (
<svg data-testid="agent-icon" className={className} />
),
ProviderIcon: ({ provider, className }: { provider: string; className?: string }) => (
<svg data-testid={`provider-icon-${provider}`} className={className} />
),
}));
// Stub webview account service.
vi.mock('../../services/webviewAccountService', () => ({
startWebviewAccountService: vi.fn(),
hideWebviewAccount: vi.fn(),
showWebviewAccount: vi.fn(),
purgeWebviewAccount: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../services/analytics', () => ({ trackEvent: vi.fn() }));
// Stub mascot subcomponents — they pull in a Rive WASM runtime.
vi.mock('../../features/human/Mascot', () => ({
RiveMascot: () => <div data-testid="rive-mascot-stub" />,
CustomGifMascot: ({ src }: { src: string }) => (
<img data-testid="custom-gif-mascot-stub" src={src} alt="" />
),
getMascotPalette: vi.fn(() => ({ bodyFill: '#4A83DD', neckShadowColor: '#2A63BD' })),
hexToArgbInt: vi.fn((_hex: string) => 0xff4a83dd),
}));
vi.mock('../../features/human/useHumanMascot', () => ({
useHumanMascot: () => ({ face: 'idle', visemeCode: 0 }),
}));
vi.mock('../../hooks/usePrewarmMostRecentAccount', () => ({
usePrewarmMostRecentAccount: vi.fn(),
}));
// ── Helpers ──────────────────────────────────────────────────────────────────
const FACE_MODE_KEY = 'chat.faceMode';
function buildStore() {
return configureStore({
reducer: {
accounts: accountsReducer,
mascot: mascotReducer,
thread: threadReducer,
chatRuntime: chatRuntimeReducer,
},
});
}
function renderAccounts(store = buildStore()) {
return render(
<Provider store={store}>
<MemoryRouter>
<Accounts />
</MemoryRouter>
</Provider>
);
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('Accounts — face-mode toggle', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('renders the face-toggle button', () => {
renderAccounts();
expect(screen.getByTestId('face-toggle-button')).toBeInTheDocument();
});
it('face mode is off by default (no face-mode-panel rendered)', () => {
renderAccounts();
expect(screen.queryByTestId('face-mode-panel')).not.toBeInTheDocument();
});
it('clicking the toggle shows the face-mode panel', async () => {
renderAccounts();
const toggle = screen.getByTestId('face-toggle-button');
await act(async () => {
fireEvent.click(toggle);
});
expect(screen.getByTestId('face-mode-panel')).toBeInTheDocument();
});
it('clicking the toggle again hides the face-mode panel', async () => {
renderAccounts();
const toggle = screen.getByTestId('face-toggle-button');
await act(async () => {
fireEvent.click(toggle);
});
expect(screen.getByTestId('face-mode-panel')).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByTestId('face-toggle-button'));
});
expect(screen.queryByTestId('face-mode-panel')).not.toBeInTheDocument();
});
it('persists face-mode ON to localStorage', async () => {
renderAccounts();
await act(async () => {
fireEvent.click(screen.getByTestId('face-toggle-button'));
});
expect(localStorage.getItem(FACE_MODE_KEY)).toBe('1');
});
it('persists face-mode OFF to localStorage after toggling twice', async () => {
renderAccounts();
await act(async () => {
fireEvent.click(screen.getByTestId('face-toggle-button'));
});
await act(async () => {
fireEvent.click(screen.getByTestId('face-toggle-button'));
});
expect(localStorage.getItem(FACE_MODE_KEY)).toBe('0');
});
it('reads face-mode ON from localStorage on mount', () => {
localStorage.setItem(FACE_MODE_KEY, '1');
renderAccounts();
expect(screen.getByTestId('face-mode-panel')).toBeInTheDocument();
});
it('when face mode is on, Conversations is rendered with variant="sidebar"', async () => {
renderAccounts();
await act(async () => {
fireEvent.click(screen.getByTestId('face-toggle-button'));
});
const conversations = screen.getByTestId('conversations-stub');
expect(conversations).toHaveAttribute('data-variant', 'sidebar');
});
});
@@ -0,0 +1,57 @@
/**
* Phase 3 — Activity route redirect tests.
*
* Verifies that:
* /intelligence → /activity (back-compat, old route rename)
* /routines → /activity?tab=automations (orphaned page removal)
* /workflows → /activity?tab=automations (old deep link back-compat)
*
* We render a minimal route tree so we do not need the full provider tree.
*/
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
/**
* Minimal route tree that mirrors only the Phase 3 redirects under test.
* Using `Navigate` directly avoids needing to mock the full app.
*/
function TestRoutes() {
return (
<Routes>
<Route path="/activity" element={<div data-testid="activity-page">activity</div>} />
<Route path="/intelligence" element={<Navigate to="/activity" replace />} />
<Route path="/routines" element={<Navigate to="/activity?tab=automations" replace />} />
<Route path="/workflows" element={<Navigate to="/activity?tab=automations" replace />} />
</Routes>
);
}
const renderAt = (path: string) =>
render(
<MemoryRouter initialEntries={[path]}>
<TestRoutes />
</MemoryRouter>
);
describe('Phase 3 route redirects', () => {
it('/intelligence redirects to /activity', () => {
renderAt('/intelligence');
expect(screen.getByTestId('activity-page')).toBeInTheDocument();
});
it('/routines redirects to /activity (Automations tab)', () => {
renderAt('/routines');
expect(screen.getByTestId('activity-page')).toBeInTheDocument();
});
it('/workflows redirects to /activity (Automations tab)', () => {
renderAt('/workflows');
expect(screen.getByTestId('activity-page')).toBeInTheDocument();
});
it('/activity renders the activity page directly', () => {
renderAt('/activity');
expect(screen.getByTestId('activity-page')).toBeInTheDocument();
});
});
+174
View File
@@ -0,0 +1,174 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// ---------------------------------------------------------------------------
// Stubs — keep the test fast by mocking every heavy dependency.
// ---------------------------------------------------------------------------
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
vi.mock('../../components/intelligence/IntelligenceSubconsciousTab', () => ({
default: () => <div data-testid="tab-backgroundActivity" />,
}));
vi.mock('../../components/intelligence/IntelligenceTasksTab', () => ({
default: () => <div data-testid="tab-tasks" />,
}));
vi.mock('../../components/intelligence/WorkflowsTab', () => ({
default: () => <div data-testid="tab-automations" />,
}));
vi.mock('../../components/intelligence/Toast', () => ({ ToastContainer: () => null }));
vi.mock('../../components/intelligence/ConfirmationModal', () => ({
ConfirmationModal: () => null,
}));
// Stub Notifications so the Alerts tab renders a predictable sentinel without
// needing a Redux store, router context beyond MemoryRouter, etc.
vi.mock('../Notifications', () => ({ default: () => <div data-testid="tab-alerts" /> }));
vi.mock('../../hooks/useIntelligenceSocket', () => ({
useIntelligenceSocket: () => ({ isConnected: true }),
useIntelligenceSocketManager: () => ({ connect: vi.fn() }),
}));
vi.mock('../../hooks/useSubconscious', () => ({
useSubconscious: () => ({
status: 'idle',
mode: 'manual',
intervalMinutes: 30,
triggering: false,
settingMode: false,
triggerTick: vi.fn(),
setMode: vi.fn(),
setIntervalMinutes: vi.fn(),
}),
}));
// Dynamic import AFTER all mocks are in place (same pattern as original test).
const Activity = (await import('../Activity')).default;
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<Activity />
</MemoryRouter>
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('Activity URL-backed tab', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('defaults to the tasks tab when no ?tab is present', async () => {
renderAt('/activity');
await waitFor(() => expect(screen.getByTestId('tab-tasks')).toBeInTheDocument());
});
it('honours ?tab=tasks from the URL', async () => {
renderAt('/activity?tab=tasks');
await waitFor(() => expect(screen.getByTestId('tab-tasks')).toBeInTheDocument());
});
it('honours ?tab=automations from the URL', async () => {
renderAt('/activity?tab=automations');
await waitFor(() => expect(screen.getByTestId('tab-automations')).toBeInTheDocument());
});
it('honours ?tab=backgroundActivity from the URL', async () => {
renderAt('/activity?tab=backgroundActivity');
await waitFor(() => expect(screen.getByTestId('tab-backgroundActivity')).toBeInTheDocument());
});
it('honours ?tab=alerts from the URL', async () => {
renderAt('/activity?tab=alerts');
await waitFor(() => expect(screen.getByTestId('tab-alerts')).toBeInTheDocument());
});
it('falls back to tasks for an unknown ?tab value', async () => {
renderAt('/activity?tab=bogus');
await waitFor(() => expect(screen.getByTestId('tab-tasks')).toBeInTheDocument());
});
// Back-compat: old deep links (?tab=memory|agents|council) are no longer
// visible Activity tabs — they should fall back to tasks rather than error.
it('falls back to tasks for ?tab=memory (relocated to Settings)', async () => {
renderAt('/activity?tab=memory');
await waitFor(() => expect(screen.getByTestId('tab-tasks')).toBeInTheDocument());
expect(screen.queryByTestId('tab-memory')).not.toBeInTheDocument();
});
it('falls back to tasks for ?tab=agents (relocated to Settings)', async () => {
renderAt('/activity?tab=agents');
await waitFor(() => expect(screen.getByTestId('tab-tasks')).toBeInTheDocument());
expect(screen.queryByTestId('tab-agents')).not.toBeInTheDocument();
});
it('falls back to tasks for ?tab=council (relocated to Settings)', async () => {
renderAt('/activity?tab=council');
await waitFor(() => expect(screen.getByTestId('tab-tasks')).toBeInTheDocument());
expect(screen.queryByTestId('tab-council')).not.toBeInTheDocument();
});
it('renders the intelligence-header data-walkthrough anchor (non-alerts tabs)', async () => {
renderAt('/activity');
await waitFor(() =>
expect(document.querySelector('[data-walkthrough="intelligence-header"]')).not.toBeNull()
);
});
});
describe('Activity tab — tab set', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders exactly four tab pills: tasks, automations, backgroundActivity, alerts', async () => {
renderAt('/activity');
await waitFor(() => screen.getByTestId('tab-tasks'));
// Each label key is returned as-is by the stub t() function.
expect(screen.getAllByText('memory.tab.tasks').length).toBeGreaterThan(0);
expect(screen.getAllByText('activity.tabs.automations').length).toBeGreaterThan(0);
expect(screen.getAllByText('activity.tabs.backgroundActivity').length).toBeGreaterThan(0);
expect(screen.getAllByText('activity.tabs.alerts').length).toBeGreaterThan(0);
});
it('does not render memory, agents, or council pills', async () => {
renderAt('/activity');
await waitFor(() => screen.getByTestId('tab-tasks'));
expect(screen.queryByText('memory.tab.memory')).not.toBeInTheDocument();
expect(screen.queryByText('memory.tab.agents')).not.toBeInTheDocument();
expect(screen.queryByText('memory.tab.council')).not.toBeInTheDocument();
});
it('clicking the automations pill switches to the automations tab', async () => {
renderAt('/activity');
await waitFor(() => screen.getAllByText('activity.tabs.automations'));
fireEvent.click(screen.getAllByText('activity.tabs.automations')[0]);
await waitFor(() => expect(screen.getByTestId('tab-automations')).toBeInTheDocument());
});
it('clicking the backgroundActivity pill switches to the backgroundActivity tab', async () => {
renderAt('/activity');
await waitFor(() => screen.getAllByText('activity.tabs.backgroundActivity'));
fireEvent.click(screen.getAllByText('activity.tabs.backgroundActivity')[0]);
await waitFor(() => expect(screen.getByTestId('tab-backgroundActivity')).toBeInTheDocument());
});
it('clicking the alerts pill switches to the alerts tab', async () => {
renderAt('/activity');
await waitFor(() => screen.getAllByText('activity.tabs.alerts'));
fireEvent.click(screen.getAllByText('activity.tabs.alerts')[0]);
await waitFor(() => expect(screen.getByTestId('tab-alerts')).toBeInTheDocument());
});
it('alerts tab renders the Notifications component', async () => {
renderAt('/activity?tab=alerts');
await waitFor(() => expect(screen.getByTestId('tab-alerts')).toBeInTheDocument());
// The card wrapper is NOT rendered in the alerts tab.
expect(screen.queryByTestId('tab-tasks')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,42 @@
/**
* Phase 6 — /human → /chat redirect test.
*
* Verifies that the desktop route for /human redirects to /chat (Assistant
* surface). iOS keeps /human as a real route in AppRoutesIOS.tsx — only
* the desktop redirect is tested here.
*
* Uses a minimal route tree so no full provider chain is needed.
*/
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
function TestRoutes() {
return (
<Routes>
{/* The real /chat route (Assistant surface). */}
<Route path="/chat" element={<div data-testid="chat-page">chat</div>} />
{/* Phase 6 back-compat redirect. */}
<Route path="/human" element={<Navigate to="/chat" replace />} />
</Routes>
);
}
const renderAt = (path: string) =>
render(
<MemoryRouter initialEntries={[path]}>
<TestRoutes />
</MemoryRouter>
);
describe('Phase 6 route redirects', () => {
it('/human redirects to /chat (Assistant surface)', () => {
renderAt('/human');
expect(screen.getByTestId('chat-page')).toBeInTheDocument();
});
it('/chat renders the chat page directly', () => {
renderAt('/chat');
expect(screen.getByTestId('chat-page')).toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
/**
* Phase 2 — route redirect tests.
*
* Verifies that:
* /skills → /connections (back-compat redirect)
* /channels → /connections?tab=messaging (orphaned page redirect)
*
* We render a minimal route tree so we do not need the full provider tree.
*/
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
/**
* Minimal route tree that mirrors only the redirects under test.
* Using `Navigate` directly avoids needing to mock the full app.
*/
function TestRoutes() {
return (
<Routes>
<Route path="/connections" element={<div data-testid="connections-page">connections</div>} />
<Route path="/skills" element={<Navigate to="/connections" replace />} />
<Route path="/channels" element={<Navigate to="/connections?tab=messaging" replace />} />
</Routes>
);
}
const renderAt = (path: string) =>
render(
<MemoryRouter initialEntries={[path]}>
<TestRoutes />
</MemoryRouter>
);
describe('Phase 2 route redirects', () => {
it('/skills redirects to /connections', () => {
renderAt('/skills');
expect(screen.getByTestId('connections-page')).toBeInTheDocument();
});
it('/channels redirects to /connections (Messaging tab)', () => {
renderAt('/channels');
expect(screen.getByTestId('connections-page')).toBeInTheDocument();
});
it('/connections renders the connections page directly', () => {
renderAt('/connections');
expect(screen.getByTestId('connections-page')).toBeInTheDocument();
});
});
@@ -1405,7 +1405,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
// Bucket tabs must be present regardless of thread count.
expect(screen.getByRole('tab', { name: 'General' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Subconscious' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Background activity' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Tasks' })).toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'All' })).not.toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Briefing' })).not.toBeInTheDocument();
@@ -1423,7 +1423,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
await openSidebar();
expect(screen.getByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: 'Subconscious' })).toHaveAttribute(
expect(screen.getByRole('tab', { name: 'Background activity' })).toHaveAttribute(
'aria-selected',
'false'
);
@@ -40,6 +40,10 @@ vi.mock('../../hooks/useIntelligenceSocket', () => ({
useIntelligenceSocket: () => ({ isConnected: true }),
useIntelligenceSocketManager: () => ({}),
}));
// useDeveloperMode needs a Redux Provider; mock it so tests render without one.
// All tabs are visible (developer mode on) — the test exercises URL routing,
// not the dev gate, so a stable open-gate is fine here.
vi.mock('../../hooks/useDeveloperMode', () => ({ useDeveloperMode: () => true }));
vi.mock('../../hooks/useSubconscious', () => ({
useSubconscious: () => ({
status: 'idle',
@@ -63,16 +63,16 @@ vi.mock('../../lib/composio/hooks', () => ({
describe('Skills page — Channels grid', () => {
beforeEach(() => {
// The default tab is 'composio'; click 'Channels' to reveal the Channels card.
// The default tab is 'composio'; click 'Messaging' to reveal the Channels card.
});
it('renders configured channels as tiles in a dedicated card and opens the setup modal on click', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
// Switch to the Channels tab to make the Channels card visible.
fireEvent.click(screen.getByRole('tab', { name: 'Channels' }));
// Switch to the Messaging tab to make the Channels card visible.
fireEvent.click(screen.getByRole('tab', { name: 'Messaging' }));
const channelsHeading = screen.getByRole('heading', { name: 'Channels' });
const channelsHeading = screen.getByRole('heading', { name: 'Messaging' });
expect(channelsHeading).toBeInTheDocument();
const channelsCard = channelsHeading.closest('.rounded-2xl');
@@ -134,11 +134,11 @@ describe('Skills page — Channels grid', () => {
},
};
renderWithProviders(<Skills />, { initialEntries: ['/skills'], preloadedState });
// Switch to the Channels tab so the Channels card is visible.
fireEvent.click(screen.getByRole('tab', { name: 'Channels' }));
renderWithProviders(<Skills />, { initialEntries: ['/connections'], preloadedState });
// Switch to the Messaging tab so the Channels card is visible.
fireEvent.click(screen.getByRole('tab', { name: 'Messaging' }));
const channelsCard = screen
.getByRole('heading', { name: 'Channels' })
.getByRole('heading', { name: 'Messaging' })
.closest('.rounded-2xl');
const telegramTile = within(channelsCard as HTMLElement).getByRole('button', {
name: new RegExp(`Telegram.*${labelPattern.source}`, 'i'),
@@ -148,11 +148,11 @@ describe('Skills page — Channels grid', () => {
);
it('does not surface a Channels chip in the category filter inside the Integrations card', () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
fireEvent.click(screen.getByRole('tab', { name: 'Composio' }));
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
fireEvent.click(screen.getByRole('tab', { name: 'Apps' }));
// The Composio tab owns the Integrations category filter.
const integrationsHeading = screen.getByRole('heading', { name: 'Composio Integrations' });
// The Apps tab owns the Integrations category filter.
const integrationsHeading = screen.getByRole('heading', { name: 'Apps' });
const integrationsCard = integrationsHeading.closest('.rounded-2xl');
expect(integrationsCard).not.toBeNull();
const filterTabs = within(integrationsCard as HTMLElement)
@@ -78,15 +78,15 @@ describe('Skills page — Composio catalog fallback', () => {
agentReadyState = { agentReady: new Set<string>(), loading: true, error: null };
});
function openComposioTab() {
fireEvent.click(screen.getByRole('tab', { name: 'Composio' }));
function openAppsTab() {
fireEvent.click(screen.getByRole('tab', { name: 'Apps' }));
}
it('shows known composio integrations in the integrations icon grid when the live toolkit list is empty', () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
openComposioTab();
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
openAppsTab();
expect(screen.getByRole('heading', { name: 'Composio Integrations' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Apps' })).toBeInTheDocument();
expect(screen.getByText('Discord')).toBeInTheDocument();
expect(screen.getByText('Google Calendar')).toBeInTheDocument();
expect(screen.getByText('Google Drive')).toBeInTheDocument();
@@ -103,7 +103,7 @@ describe('Skills page — Composio catalog fallback', () => {
// missing Composio Zoom tile even though the Meeting bots card also
// renders a "Zoom" entry on the same page.
const integrationsSection = screen
.getByRole('heading', { name: 'Composio Integrations' })
.getByRole('heading', { name: 'Apps' })
.closest('.rounded-2xl');
expect(integrationsSection).not.toBeNull();
expect(within(integrationsSection as HTMLElement).getByText('Zoom')).toBeInTheDocument();
@@ -113,14 +113,14 @@ describe('Skills page — Composio catalog fallback', () => {
it('shows a stale/error state instead of disconnected toolkits when composio loading fails', () => {
composioError = 'Backend unavailable';
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
openComposioTab();
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
openAppsTab();
expect(screen.getByText('Connections are showing stale status')).toBeInTheDocument();
expect(screen.getByText('Backend unavailable')).toBeInTheDocument();
const integrationsSection = screen
.getByRole('heading', { name: 'Composio Integrations' })
.getByRole('heading', { name: 'Apps' })
.closest('.rounded-2xl');
expect(integrationsSection).not.toBeNull();
const gmailTile = within(integrationsSection as HTMLElement).getByRole('button', {
@@ -139,11 +139,11 @@ describe('Skills page — Composio catalog fallback', () => {
['gmail', { id: 'ca_expired', toolkit: 'gmail', status: 'EXPIRED' }],
]);
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
openComposioTab();
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
openAppsTab();
const integrationsSection = screen
.getByRole('heading', { name: 'Composio Integrations' })
.getByRole('heading', { name: 'Apps' })
.closest('.rounded-2xl');
expect(integrationsSection).not.toBeNull();
const gmailTile = within(integrationsSection as HTMLElement).getByRole('button', {
@@ -174,11 +174,11 @@ describe('Skills page — Composio catalog fallback', () => {
]);
agentReadyState = { agentReady: new Set(['gmail']), loading: false, error: null };
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
openComposioTab();
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
openAppsTab();
const integrationsSection = screen
.getByRole('heading', { name: 'Composio Integrations' })
.getByRole('heading', { name: 'Apps' })
.closest('.rounded-2xl');
expect(integrationsSection).not.toBeNull();
expect(within(integrationsSection as HTMLElement).getByText('2')).toBeInTheDocument();
@@ -194,11 +194,11 @@ describe('Skills page — Composio catalog fallback', () => {
// misrepresenting the agent surface.
agentReadyState = { agentReady: new Set<string>(), loading: false, error: 'rpc unavailable' };
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
openComposioTab();
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
openAppsTab();
const integrationsSection = screen
.getByRole('heading', { name: 'Composio Integrations' })
.getByRole('heading', { name: 'Apps' })
.closest('.rounded-2xl');
expect(integrationsSection).not.toBeNull();
// No Preview badges anywhere in the integrations grid. The
@@ -218,11 +218,11 @@ describe('Skills page — Composio catalog fallback', () => {
]);
agentReadyState = { agentReady: new Set<string>(['gmail']), loading: false, error: null };
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
openComposioTab();
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
openAppsTab();
const integrationsSection = screen
.getByRole('heading', { name: 'Composio Integrations' })
.getByRole('heading', { name: 'Apps' })
.closest('.rounded-2xl');
expect(integrationsSection).not.toBeNull();
const zohoTile = within(integrationsSection as HTMLElement).getByRole('button', {
@@ -243,8 +243,8 @@ describe('Skills page — Composio catalog fallback', () => {
sessionToken = 'header.payload.local';
composioModeStatus = { result: { mode: 'direct', api_key_set: false }, logs: [] };
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
openComposioTab();
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
openAppsTab();
await waitFor(() => {
expect(screen.getByText(/No Composio API Key Configured/i)).toBeInTheDocument();
@@ -49,13 +49,13 @@ vi.mock('../../services/api/mcpClientsApi', () => ({
},
}));
describe('Skills page — MCP tab', () => {
it('renders the live MCP servers tab with unified table view (not a coming-soon placeholder)', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
describe('Skills page — Tools tab (MCP + Meeting bots, Phase 2)', () => {
it('renders the MCP servers table in the Tools tab', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' }));
fireEvent.click(screen.getByRole('tab', { name: 'Tools' }));
// The new tab shows filter chips (All / Installed / Registry) and a search input
// The Tools tab shows filter chips (All / Installed / Registry) and a search input
await waitFor(() => {
expect(screen.getByRole('button', { name: 'All' })).toBeInTheDocument();
});
@@ -63,10 +63,10 @@ describe('Skills page — MCP tab', () => {
expect(screen.getByRole('button', { name: 'Registry' })).toBeInTheDocument();
});
it('shows the table header columns on the MCP tab', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
it('shows the table header columns on the Tools tab', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' }));
fireEvent.click(screen.getByRole('tab', { name: 'Tools' }));
// Wait for initial load to complete
await waitFor(() => {
@@ -79,9 +79,9 @@ describe('Skills page — MCP tab', () => {
});
it('shows empty-installed state when Installed chip is clicked', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
fireEvent.click(screen.getByRole('tab', { name: 'MCP Servers' }));
fireEvent.click(screen.getByRole('tab', { name: 'Tools' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: /Installed/i })).toBeInTheDocument();
@@ -92,4 +92,13 @@ describe('Skills page — MCP tab', () => {
expect(screen.getByText('No MCP servers installed yet.')).toBeInTheDocument();
});
});
it('supports direct links via legacy ?tab=mcp (normalised to tools)', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/connections?tab=mcp'] });
expect(screen.getByRole('tab', { name: 'Tools' })).toHaveAttribute('aria-selected', 'true');
await waitFor(() => {
expect(screen.getByRole('button', { name: 'All' })).toBeInTheDocument();
});
});
});
@@ -38,24 +38,33 @@ vi.mock('../../lib/composio/hooks', () => ({
}),
}));
describe('Skills page — Meetings tab', () => {
it('keeps the meeting bot CTA in its own Connections tab', () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
describe('Skills page — Talents tab (meeting bots)', () => {
it('shows the meeting bot CTA inside the Talents tab (not Tools)', () => {
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
expect(screen.queryByTestId('meeting-bots-card')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: 'Google Meet' }));
// Tools no longer hosts the meeting bot CTA.
fireEvent.click(screen.getByRole('tab', { name: 'Tools' }));
expect(screen.queryByTestId('meeting-bots-card')).not.toBeInTheDocument();
// Talents does.
fireEvent.click(screen.getByRole('tab', { name: 'Talents' }));
expect(screen.getByTestId('meeting-bots-card')).toBeInTheDocument();
});
it('supports direct links to the Meetings tab', () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills?tab=meetings'] });
it('supports direct links via legacy ?tab=meetings (normalised to talents)', () => {
// The old ?tab=meetings alias now maps to the new "Talents" tab.
renderWithProviders(<Skills />, { initialEntries: ['/connections?tab=meetings'] });
expect(screen.getByRole('tab', { name: 'Google Meet' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(screen.getByRole('tab', { name: 'Talents' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByTestId('meeting-bots-card')).toBeInTheDocument();
});
it('supports direct links via ?tab=talents', () => {
renderWithProviders(<Skills />, { initialEntries: ['/connections?tab=talents'] });
expect(screen.getByRole('tab', { name: 'Talents' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByTestId('meeting-bots-card')).toBeInTheDocument();
});
});
@@ -40,11 +40,11 @@ vi.mock('../../lib/composio/hooks', () => ({
describe('Skills page — Gmail composio integration', () => {
it('renders Gmail as a connected composio integration and opens its management modal', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
fireEvent.click(screen.getByRole('tab', { name: 'Composio' }));
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
fireEvent.click(screen.getByRole('tab', { name: 'Apps' }));
const integrationsSection = screen
.getByRole('heading', { name: 'Composio Integrations' })
.getByRole('heading', { name: 'Apps' })
.closest('.rounded-2xl');
expect(integrationsSection).not.toBeNull();
expect(within(integrationsSection as HTMLElement).getByText('Gmail')).toBeInTheDocument();
@@ -36,10 +36,10 @@ vi.mock('../../lib/composio/hooks', () => ({
describe('Skills page — Notion composio integration', () => {
it('renders Notion as a disconnected composio integration and opens its connect modal', async () => {
renderWithProviders(<Skills />, { initialEntries: ['/skills'] });
fireEvent.click(screen.getByRole('tab', { name: 'Composio' }));
renderWithProviders(<Skills />, { initialEntries: ['/connections'] });
fireEvent.click(screen.getByRole('tab', { name: 'Apps' }));
expect(screen.getByRole('heading', { name: 'Composio Integrations' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Apps' })).toBeInTheDocument();
const notionTile = screen.getByRole('button', { name: /Notion.*Connect/i });
expect(notionTile).toBeInTheDocument();
+55
View File
@@ -0,0 +1,55 @@
/**
* Tests for the `developerMode` field added to themeSlice and the
* `selectDeveloperMode` selector.
*
* The field is persisted via the `theme` redux-persist config (localStorage,
* same as other theme preferences). These tests validate the reducer + selector
* in isolation without requiring the full persist machinery.
*/
import { describe, expect, it } from 'vitest';
import themeReducer, { selectDeveloperMode, setDeveloperMode } from './themeSlice';
const initialState = themeReducer(undefined, { type: '@@INIT' });
describe('themeSlice — developerMode field', () => {
it('defaults developerMode to false', () => {
expect(initialState.developerMode).toBe(false);
});
it('enables developerMode via setDeveloperMode(true)', () => {
const state = themeReducer(initialState, setDeveloperMode(true));
expect(state.developerMode).toBe(true);
});
it('disables developerMode via setDeveloperMode(false)', () => {
const withDevMode = themeReducer(initialState, setDeveloperMode(true));
const back = themeReducer(withDevMode, setDeveloperMode(false));
expect(back.developerMode).toBe(false);
});
it('leaves all other theme fields untouched when toggling developerMode', () => {
const { developerMode: _, ...restBefore } = initialState;
const withDevMode = themeReducer(initialState, setDeveloperMode(true));
const { developerMode: _2, ...restAfter } = withDevMode;
expect(restAfter).toEqual(restBefore);
});
});
describe('selectDeveloperMode', () => {
it('returns false from a fresh store', () => {
const result = selectDeveloperMode({ theme: initialState });
expect(result).toBe(false);
});
it('returns true after setDeveloperMode(true)', () => {
const state = themeReducer(initialState, setDeveloperMode(true));
expect(selectDeveloperMode({ theme: state })).toBe(true);
});
it('returns false after toggling back to false', () => {
const enabled = themeReducer(initialState, setDeveloperMode(true));
const disabled = themeReducer(enabled, setDeveloperMode(false));
expect(selectDeveloperMode({ theme: disabled })).toBe(false);
});
});
+1 -1
View File
@@ -95,7 +95,7 @@ const persistedLocaleReducer = persistReducer(localePersistConfig, localeReducer
const themePersistConfig = {
key: 'theme',
storage: localStorageAdapter,
whitelist: ['mode', 'tabBarLabels', 'fontSize', 'agentMessageViewMode'],
whitelist: ['mode', 'tabBarLabels', 'fontSize', 'agentMessageViewMode', 'developerMode'],
};
const persistedThemeReducer = persistReducer(themePersistConfig, themeReducer);
+1
View File
@@ -38,6 +38,7 @@ describe('themeSlice', () => {
tabBarLabels: 'always',
fontSize: 'xlarge',
agentMessageViewMode: 'bubbles',
developerMode: false,
});
});
+26 -2
View File
@@ -28,6 +28,14 @@ interface ThemeState {
tabBarLabels: TabBarLabels;
fontSize: FontSize;
agentMessageViewMode: AgentMessageViewMode;
/**
* Runtime Developer Mode (default OFF).
* When true, all developer and diagnostic surfaces become visible.
* Combines with the build-time `IS_DEV` flag — either one enables the gate.
* Gating is UI-only: the Rust SecurityPolicy / autonomy tier enforcement
* is authoritative and is never relaxed by this toggle.
*/
developerMode: boolean;
}
const initialState: ThemeState = {
@@ -35,6 +43,7 @@ const initialState: ThemeState = {
tabBarLabels: 'hover',
fontSize: 'medium',
agentMessageViewMode: 'bubbles',
developerMode: false,
};
const themeSlice = createSlice({
@@ -53,13 +62,28 @@ const themeSlice = createSlice({
setAgentMessageViewMode(state, action: PayloadAction<AgentMessageViewMode>) {
state.agentMessageViewMode = action.payload;
},
setDeveloperMode(state, action: PayloadAction<boolean>) {
state.developerMode = action.payload;
},
},
});
export const { setThemeMode, setTabBarLabels, setFontSize, setAgentMessageViewMode } =
themeSlice.actions;
export const {
setThemeMode,
setTabBarLabels,
setFontSize,
setAgentMessageViewMode,
setDeveloperMode,
} = themeSlice.actions;
export default themeSlice.reducer;
/**
* Selector for the persisted `developerMode` preference.
* Use {@link useDeveloperMode} in components — it combines this with `IS_DEV`.
*/
export const selectDeveloperMode = (state: { theme: ThemeState }): boolean =>
state.theme.developerMode;
/**
* Resolves a `ThemeMode` to the concrete `light` or `dark` value that should
* be applied to `<html>`. `system` consults `prefers-color-scheme`; in non-DOM
+1 -1
View File
@@ -292,7 +292,7 @@ const handleOAuthDeepLink = async (parsed: URL) => {
`[DeepLink] OAuth success for integration=${integrationId}${toolkit ? ` toolkit=${toolkit}` : ''}`
);
window.dispatchEvent(new CustomEvent('oauth:success', { detail: { integrationId, toolkit } }));
window.location.hash = '/skills';
window.location.hash = '/connections';
} else if (path === 'error') {
const provider = sanitizeOAuthDiagnosticValue(
parsed.searchParams.get('provider'),
+30
View File
@@ -0,0 +1,30 @@
/**
* Resolve a human-friendly display name from a core-owned user snapshot,
* tolerating both camelCase and snake_case identity fields. Returns the
* literal `'User'` when nothing usable is present.
*
* Shared by the Home greeting and the bottom-bar avatar so both derive the
* same name from the same source.
*/
export function resolveUserName(user: unknown): string {
if (!user || typeof user !== 'object') return 'User';
const record = user as Record<string, unknown>;
const firstName =
(typeof record.firstName === 'string' && record.firstName.trim()) ||
(typeof record.first_name === 'string' && record.first_name.trim()) ||
'';
const lastName =
(typeof record.lastName === 'string' && record.lastName.trim()) ||
(typeof record.last_name === 'string' && record.last_name.trim()) ||
'';
const username = typeof record.username === 'string' ? record.username.trim() : '';
const email = typeof record.email === 'string' ? record.email.trim() : '';
const fullName = [firstName, lastName].filter(Boolean).join(' ').trim();
if (fullName) return fullName;
if (firstName) return firstName;
if (username) return username.startsWith('@') ? username : `@${username}`;
if (email) return email.split('@')[0] || 'User';
return 'User';
}
+25 -6
View File
@@ -120,12 +120,16 @@ export async function clickFirstMatch(candidates, timeout = 5_000) {
/** Appium Mac2 cannot run W3C Execute Script in WKWebView — use sidebar labels instead. */
const HASH_TO_SIDEBAR_LABEL = {
'/skills': 'Skills',
// Phase 2/3 IA revamp: /skills → /connections, /intelligence → /activity
'/connections': 'Connections',
'/activity': 'Activity',
'/home': 'Home',
'/chat': 'Chat',
'/chat': 'Assistant',
'/notifications': 'Alerts',
'/settings': 'Settings',
'/intelligence': 'Intelligence',
// Back-compat: old routes redirect — keep entries so existing callers still work
'/skills': 'Connections',
'/intelligence': 'Activity',
};
function normalizeHash(value) {
@@ -143,7 +147,10 @@ function routeReadySelector(hash) {
'/settings/migration': '[data-testid="migration-form"]',
'/settings/voice': '[data-testid="voice-providers-section"]',
'/settings/memory-data': '[data-testid="memory-workspace"]',
'/intelligence': '[data-testid="memory-workspace"]',
// Phase 3: /intelligence → /activity; memory-workspace is dev-gated (tab=memory).
// Use a non-dev-gated selector for the activity route instead.
'/intelligence': '[data-testid="intelligence-tasks"]',
'/activity': '[data-testid="intelligence-tasks"]',
};
return selectors[path] || null;
}
@@ -425,12 +432,24 @@ export async function navigateToBilling() {
console.log('[E2E] Billing page loaded (after fallback)');
}
/** @deprecated Phase 2: use navigateToConnections() instead. Still works via redirect. */
export async function navigateToSkills() {
await navigateViaHash('/skills');
await navigateViaHash('/connections');
}
/** Navigate to the Connections page (was /skills in Phase 1). */
export async function navigateToConnections() {
await navigateViaHash('/connections');
}
/** @deprecated Phase 3: use navigateToActivity() instead. Still works via redirect. */
export async function navigateToIntelligence() {
await navigateViaHash('/intelligence');
await navigateViaHash('/activity');
}
/** Navigate to the Activity page (was /intelligence in Phase 2). */
export async function navigateToActivity() {
await navigateViaHash('/activity');
}
export async function navigateToConversations() {
@@ -144,7 +144,8 @@ suiteRunner('Conversations web channel flow', () => {
expect(sent).toBe(true);
await waitForText(uniquePayload, 20_000);
await navigateViaHash('/skills');
// Phase 2: /skills → /connections
await navigateViaHash('/connections');
await browser.pause(1_500);
await navigateToConversations();
+6 -6
View File
@@ -6,7 +6,7 @@
*
* 1. Skills gate: start tour, reach the skills step, confirm skills UI is
* present. The tooltip advances via Next — the current implementation
* navigates to /skills and highlights the grid via a `before` async hook
* navigates to /connections (was /skills) and highlights the grid via a `before` async hook
* in walkthroughSteps.ts. The test polls for the hash change rather than
* reading it immediately, because the Joyride `before` hook is awaited
* asynchronously and the hash may lag by a render cycle.
@@ -33,7 +33,7 @@
* assertion is skipped and documented as GP-2.
*
* Product gaps surfaced (skipped):
* - GP-1: No skill-connection gate on the /skills tour step.
* - GP-1: No skill-connection gate on the /connections tour step (Phase 2: was /skills).
* - GP-2: No step-index persistence — tour always restarts from step 0
* on reload rather than resuming at the last incomplete step.
* - GP-3: No API to jump to an arbitrary Joyride step — the only way to
@@ -243,8 +243,8 @@ describe('Guided tour — gates and resume behaviour (#1215)', function () {
// appears after dispatchWalkthroughRestart() when Joyride has already
// completed a prior run on the same mounted instance. Without the
// tooltip, advanceTourSteps() finds no primary button and the hash
// stays at #/home instead of advancing to #/skills.
it.skip('tour navigates to /skills and highlights skills-grid after 3 Next clicks', async () => {
// stays at #/home instead of advancing to #/connections (Phase 2: was #/skills).
it.skip('tour navigates to /connections and highlights skills-grid after 3 Next clicks', async () => {
// SKIPPED — depends on tooltip appearing at step 1, which is blocked by
// the same Joyride run-state issue documented above. Re-enable once
// AppWalkthrough forces a step-index reset on walkthrough:restart.
@@ -256,7 +256,7 @@ describe('Guided tour — gates and resume behaviour (#1215)', function () {
// hold the "Next" button disabled until a `openhuman.workflows_list` RPC
// call confirms at least one skill is connected, then re-enable it.
it.skip('GP-1 (NOT IMPLEMENTED): tour Next button is disabled until user connects a skill', async () => {
// Expected product behaviour: the Next button on the /skills step
// Expected product behaviour: the Next button on the /connections step (Phase 2: was /skills)
// should remain disabled (`aria-disabled="true"` or `disabled`) while
// no skill is connected, and become enabled only after the
// `skills.skill_connected` event fires or a polling RPC returns >= 1
@@ -391,7 +391,7 @@ describe('Guided tour — gates and resume behaviour (#1215)', function () {
// when AppWalkthrough mounts with `run=true`.
it.skip('GP-2 (NOT IMPLEMENTED): tour resumes at last incomplete step after reload', async () => {
// Expected product behaviour:
// 1. User advances to step 4 (/skills).
// 1. User advances to step 4 (/connections — was /skills before Phase 2).
// 2. App is closed (renderer reloaded) before the tour finishes.
// 3. On reopen the tour shows step 4, not step 0.
//
@@ -9,8 +9,8 @@ import { startMockServer, stopMockServer } from '../mock-server';
/**
* E2E for the global memory-sync schedule control (#3302).
*
* Drives the real React UI + in-process core: navigate to Intelligence
* Memory, confirm the schedule defaults to "Every 24h", pick the 4h preset,
* Drives the real React UI + in-process core: navigate to Settings → Account
* Data Sync, confirm the schedule defaults to "Every 24h", pick the 4h preset,
* confirm the summary updates, then re-navigate to prove the choice persisted
* through `config_update_memory_sync_settings` / `config_get_memory_sync_settings`.
*
@@ -27,11 +27,11 @@ function stepLog(message: string, context?: unknown): void {
}
async function gotoMemoryWorkspace(): Promise<void> {
// HashRouter + useSearchParams: the Memory tab lives at
// `#/intelligence?tab=memory`, whose default sub-tab renders the
// MemoryWorkspace (and the MemorySourcesRegistry schedule control).
// The memory-sync schedule control lives on the layman Data Sync page
// (Settings → Account → Data Sync), which renders MemorySourcesRegistry and
// its schedule control without needing developer mode.
await browser.execute(() => {
window.location.hash = '/intelligence?tab=memory';
window.location.hash = '/settings/memory-sync';
});
await browser.pause(2_000);
}
@@ -85,7 +85,7 @@ describe('Memory sync schedule', () => {
// Navigate away and back to prove the value was persisted server-side.
await browser.execute(() => {
window.location.hash = '/intelligence?tab=tasks';
window.location.hash = '/settings';
});
await browser.pause(1_000);
await gotoMemoryWorkspace();
@@ -22,6 +22,11 @@ const USER_ID = 'e2e-navigation-smoothness';
const ROUTE_TIMEOUT = 10_000;
// Routes to visit, with optional text markers that confirm the panel loaded.
// Phase 2/3/6 IA revamp:
// /skills → /connections (back-compat redirect); use canonical route here
// /intelligence → /activity (back-compat redirect); use canonical route here
// /channels → /connections?tab=messaging (redirect)
// /human → /chat (Phase 6 redirect)
interface RouteCheck {
hash: string;
markers: string[];
@@ -29,36 +34,18 @@ interface RouteCheck {
const ROUTES: RouteCheck[] = [
{ hash: '/chat', markers: ['Threads', 'Chat', 'Message', 'New thread'] },
{ hash: '/skills', markers: ['Skills', 'Skill', 'Install', 'Browse'] },
{
hash: '/home',
markers: [
'Good morning',
'Good afternoon',
'Good evening',
'Message OpenHuman',
'Test',
'Upgrade',
],
},
{ hash: '/channels', markers: ['Channels', 'Channel', 'Connect', 'Add', 'Gmail', 'Telegram'] },
// Connections page (was /skills) — tabs: Apps, Messaging, Tools, Explorer
{ hash: '/connections', markers: ['Apps', 'Messaging', 'Tools', 'Connections'] },
{ hash: '/home', markers: ['Ask your assistant anything', 'Your device is connected', 'Home'] },
{
hash: '/notifications',
markers: ['Notifications', 'Alerts', 'Notification', 'No notifications'],
},
{ hash: '/rewards', markers: ['Rewards', 'Referral', 'Credits', 'Earn', 'Invite'] },
{ hash: '/settings', markers: ['Settings', 'Account', 'Billing', 'Advanced'] },
{
hash: '/home',
markers: [
'Good morning',
'Good afternoon',
'Good evening',
'Message OpenHuman',
'Test',
'Upgrade',
],
},
// Activity page (was /intelligence) — tabs: Tasks, Automations, Background activity
{ hash: '/activity', markers: ['Tasks', 'Automations', 'Background', 'Activity'] },
{ hash: '/home', markers: ['Ask your assistant anything', 'Your device is connected', 'Home'] },
];
async function rootTextLength(): Promise<number> {
@@ -109,7 +96,7 @@ describe('Navigation smoothness', () => {
console.log(`${LOG_PREFIX} Teardown complete`);
});
it('N1.1 — all 8 major routes render without error within timing budget', async () => {
it('N1.1 — all major routes render without error within timing budget', async () => {
console.log(`${LOG_PREFIX} N1.1: first pass — normal navigation`);
for (const route of ROUTES) {
console.log(`${LOG_PREFIX} N1.1: navigating to ${route.hash}`);
+6 -3
View File
@@ -28,12 +28,15 @@ interface Route {
minChars?: number;
}
// Phase 2/3/6 IA revamp:
// /human → /chat (Phase 6 — back-compat redirect)
// /skills → /connections (Phase 2 — back-compat redirect)
// /intelligence → /activity (Phase 3 — back-compat redirect)
const ROUTES: Route[] = [
{ hash: '/home' },
{ hash: '/human' },
{ hash: '/chat' },
{ hash: '/skills' },
{ hash: '/intelligence' },
{ hash: '/connections' },
{ hash: '/activity' },
{ hash: '/rewards' },
{ hash: '/settings' },
];
@@ -30,8 +30,9 @@ describe('Settings - Channels & Permissions', () => {
});
it('allows switching default messaging channel (13.2.1)', async () => {
// Default Messaging Channel UI moved from /settings/messaging to /skills (channels tab).
await navigateViaHash('/skills?tab=channels');
// Phase 2: Default Messaging Channel UI is at /connections (Messaging tab).
// Old /skills?tab=channels → /connections?tab=messaging.
await navigateViaHash('/connections?tab=messaging');
await waitForText('Default Messaging Channel', 15_000);
expect(await textExists('Telegram')).toBe(true);
@@ -75,15 +75,16 @@ describe('Settings - Feature Preferences', () => {
await waitForText('Features', 15_000);
// Settings uses t('pages.settings.features.screenAwareness') = 'Screen awareness'
await waitForText('Screen awareness', 15_000);
// Settings uses t('pages.settings.features.messagingChannels') = 'Messaging channels'
await waitForText('Messaging channels', 15_000);
// Phase 2: default messaging channel moved to /connections (Messaging tab);
// the settings/features panel no longer has a "Messaging channels" entry.
await waitForText('Notifications', 15_000);
await waitForText('Tools', 15_000);
});
it('persists the default messaging channel through redux state', async () => {
// Default Messaging Channel moved from /settings/messaging to /skills (channels tab).
await navigateViaHash('/skills?tab=channels');
// Phase 2: Default Messaging Channel moved to /connections (Messaging tab).
// Old /skills?tab=channels → /connections?tab=messaging.
await navigateViaHash('/connections?tab=messaging');
await waitForText('Default Messaging Channel', 15_000);
await clickText('Discord', 10_000);
@@ -40,12 +40,14 @@ describe('Skill discovery (UI + core RPC)', () => {
expect(ping.ok).toBe(true);
});
it('Skills UI surface shows installed tools', async () => {
it('Connections UI surface shows installed tools', async () => {
// Phase 2: navigateToSkills() now navigates to /connections
await navigateToSkills();
await browser.pause(2_000);
const hash = await browser.execute(() => window.location.hash);
expect(String(hash)).toContain('/skills');
// Phase 2: /skills redirects to /connections
expect(String(hash)).toContain('/connections');
const visible =
(await textExists('Skills')) ||
+14 -9
View File
@@ -3,7 +3,7 @@
* Skill lifecycle smoke (issue #224).
*
* Drives auth → onboarding → Skills page and asserts:
* 1. The route mounts (`#/skills`).
* 1. The route mounts (`#/connections` — was `#/skills` before Phase 2).
* 2. The Skills shell renders one of the well-known affordances
* (Skills/Install/Available header).
*
@@ -33,21 +33,26 @@ describe('Skill lifecycle smoke', () => {
await stopMockServer();
});
it('Skills page mounts and fetched the registry', async () => {
it('Connections page mounts and fetched the registry', async () => {
// Phase 2: navigateToSkills() now points to /connections
await navigateToSkills();
await browser.waitUntil(
async () => String(await browser.execute(() => window.location.hash)).includes('/skills'),
{ timeout: 10_000, interval: 250, timeoutMsg: 'Skills route did not mount in time' }
// Phase 2: /skills redirects to /connections
async () =>
String(await browser.execute(() => window.location.hash)).includes('/connections'),
{ timeout: 10_000, interval: 250, timeoutMsg: 'Connections route did not mount in time' }
);
const hash = await browser.execute(() => window.location.hash);
expect(String(hash)).toContain('/skills');
// Phase 2: /skills → /connections
expect(String(hash)).toContain('/connections');
// Skills page now shows "Connections" title with Composio/Channels/MCP tabs.
// Connections page shows tabs: Apps (was Composio), Messaging (was Channels), Tools (was MCP)
const visible =
(await textExists('Connections')) ||
(await textExists('Composio')) ||
(await textExists('MCP Servers'));
(await textExists('Apps')) ||
(await textExists('Messaging')) ||
(await textExists('Tools')) ||
(await textExists('Connections'));
expect(visible).toBe(true);
// Verify the core RPC route for skills is reachable. The Skills page
+8 -5
View File
@@ -53,18 +53,21 @@ describe('Skills registry flow', () => {
await stopMockServer();
});
it('navigates to /skills and renders catalog content', async () => {
it('navigates to /connections and renders catalog content', async () => {
clearRequestLog();
// Phase 2: navigateToSkills() now navigates to /connections
await navigateToSkills();
const currentHash = await browser.execute(() => window.location.hash);
expect(String(currentHash)).toContain('/skills');
// Phase 2: /skills → /connections
expect(String(currentHash)).toContain('/connections');
await browser.pause(2_000);
// Phase 2: tab labels changed — Apps/Messaging/Tools instead of Composio/Channels/MCP
const hasSkillsContent =
(await textExists('Install')) ||
(await textExists('Available')) ||
(await textExists('Skills')) ||
(await textExists('Apps')) ||
(await textExists('Messaging')) ||
(await textExists('Tools')) ||
(await textExists('Telegram')) ||
(await textExists('Notion'));
+6 -4
View File
@@ -251,19 +251,21 @@ describe.skip('Voice mode — Human tab capture & error mapping (#1610)', () =>
});
// ---------------------------------------------------------------------------
// Helper: navigate to the Human tab via hash routing.
// Helper: navigate to the Assistant (Chat) tab via hash routing.
// Phase 6: /human merged into /chat (Assistant surface). /human redirects.
// ---------------------------------------------------------------------------
async function navigateToHumanTab(): Promise<void> {
if (supportsExecuteScript()) {
await browser.execute(() => {
window.location.hash = '#/human';
// Phase 6: /human → /chat (back-compat redirect preserved)
window.location.hash = '#/chat';
});
} else {
// Mac2 path: use the shared helper which abstracts the XCUIElementTypeButton
// XPath so the selector stays cross-driver and policy-compliant.
await clickNativeButton('Human');
await clickNativeButton('Assistant');
}
// Allow React router to settle and the Human page to mount.
// Allow React router to settle and the Chat page to mount.
await browser.pause(1_500);
}
@@ -15,10 +15,10 @@ test.describe('Channels Smoke', () => {
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
await expect(page.getByText('Channels')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Telegram', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: /Telegram Disconnected/ })).toBeVisible();
await expect(page.getByRole('button', { name: /Discord Disconnected/ })).toBeVisible();
await expect(page.getByRole('button', { name: 'Connect' }).first()).toBeVisible();
// /channels redirects to /connections?tab=messaging; connectors now render
// as ChannelTile buttons named "<Name>, <status>. <cta>." (not headings/
// standalone Disconnected buttons).
await expect(page.getByRole('button', { name: /Telegram/ }).first()).toBeVisible();
await expect(page.getByRole('button', { name: /Discord/ }).first()).toBeVisible();
});
});
@@ -31,8 +31,8 @@ test.describe('Command Palette', () => {
await expect(page.getByText('Go Home')).toBeVisible();
await expect(page.getByText('Go to Chat')).toBeVisible();
await expect(page.getByText('Go to Intelligence')).toBeVisible();
await expect(page.getByText('Go to Skills')).toBeVisible();
await expect(page.getByText('Go to Knowledge & Memory')).toBeVisible();
await expect(page.getByText('Go to Connections')).toBeVisible();
await expect(page.getByText('Open Settings')).toBeVisible();
await page.keyboard.press('Escape');
@@ -74,24 +74,20 @@ async function bootSkillsPage(page: Page, userId: string) {
localStorage.setItem('openhuman:walkthrough_completed', 'true');
localStorage.removeItem('openhuman:walkthrough_pending');
} catch {}
window.location.hash = '/skills';
// Phase 2: /skills → /connections
window.location.hash = '/connections';
});
await expect
.poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 })
.toContain('/skills');
.toContain('/connections');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
await page.getByRole('tab', { name: 'Composio' }).click();
const heading = page.getByRole('heading', { name: 'Composio Integrations' });
if (!(await heading.isVisible().catch(() => false))) {
const connectionsButton = page.getByRole('button', { name: 'Connections' });
if (await connectionsButton.isVisible().catch(() => false)) {
await connectionsButton.click({ force: true });
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
}
}
await expect(heading).toBeVisible({ timeout: 20_000 });
// Phase 2: "Composio" tab renamed to "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
// Phase 2: heading is now "Apps" (skills.integrations), "Composio Integrations" removed
await expect(page.getByRole('heading', { name: 'Apps', exact: true })).toBeVisible({
timeout: 20_000,
});
}
async function openGmailManageModal(page: Page) {
@@ -190,8 +186,9 @@ test.describe('Composio triggers flow', () => {
await page.reload();
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
await page.getByRole('tab', { name: 'Composio' }).click();
await expect(page.getByRole('heading', { name: 'Composio Integrations' })).toBeVisible({
// Phase 2: "Composio" tab renamed to "Apps"; heading is now "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
await expect(page.getByRole('heading', { name: 'Apps', exact: true })).toBeVisible({
timeout: 20_000,
});
@@ -66,28 +66,30 @@ async function bootSkillsPage(page: Page, userId: string) {
localStorage.removeItem('openhuman:walkthrough_pending');
} catch {}
});
// Phase 2: /skills → /connections
await page.evaluate(() => {
window.location.hash = '/skills';
window.location.hash = '/connections';
});
await expect
.poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 })
.toContain('/skills');
.toContain('/connections');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
await page.getByRole('tab', { name: 'Composio' }).click();
const heading = page.getByRole('heading', { name: 'Composio Integrations' });
// Phase 2: "Composio" tab renamed to "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
const heading = page.getByRole('heading', { name: 'Apps', exact: true });
if (!(await heading.isVisible().catch(() => false))) {
const connectionsButton = page.getByRole('button', { name: 'Connections' });
if (await connectionsButton.isVisible().catch(() => false)) {
await connectionsButton.click({ force: true });
await expect
.poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 })
.toContain('/skills');
.toContain('/connections');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
}
}
await expect(page.getByRole('heading', { name: 'Composio Integrations' })).toBeVisible({
await expect(page.getByRole('heading', { name: 'Apps', exact: true })).toBeVisible({
timeout: 20_000,
});
}
@@ -97,17 +99,18 @@ async function reloadSkills(page: Page) {
}
async function ensureComposioSurface(page: Page) {
const heading = page.getByRole('heading', { name: 'Composio Integrations' });
// Phase 2: /skills → /connections, "Composio" tab renamed to "Apps"
const heading = page.getByRole('heading', { name: 'Apps', exact: true });
for (let attempt = 0; attempt < 3; attempt++) {
await page.evaluate(() => {
window.location.hash = '/skills';
window.location.hash = '/connections';
});
await expect
.poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 })
.toContain('/skills');
.toContain('/connections');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
await page.getByRole('tab', { name: 'Composio' }).click();
await page.getByRole('tab', { name: 'Apps' }).click();
if (await heading.isVisible().catch(() => false)) {
return;
}
@@ -116,7 +119,7 @@ async function ensureComposioSurface(page: Page) {
await connectionsButton.click({ force: true });
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
await page.getByRole('tab', { name: 'Composio' }).click();
await page.getByRole('tab', { name: 'Apps' }).click();
if (await heading.isVisible().catch(() => false)) {
return;
}
@@ -126,6 +129,7 @@ async function ensureComposioSurface(page: Page) {
}
async function assertSessionNotNuked(page: Page) {
// Phase 2: /skills → /connections
await expect
.poll(async () =>
page.evaluate(() => {
@@ -145,7 +149,7 @@ async function assertSessionNotNuked(page: Page) {
};
})
)
.toEqual({ hash: '#/skills', hasToken: true, hasUser: true });
.toEqual({ hash: '#/connections', hasToken: true, hasUser: true });
}
async function openModal(page: Page) {
@@ -69,16 +69,18 @@ async function bootSkills(page: Page, userId: string): Promise<void> {
await seedToolkits('ACTIVE');
await bootRuntimeReadyGuestPage(page);
await signInViaCallbackToken(page, userId);
await page.goto('/#/skills');
// Phase 2: /skills → /connections, "Composio" tab renamed to "Apps"
await page.goto('/#/connections');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
await page.getByRole('tab', { name: 'Composio' }).click();
await expect(page.getByRole('heading', { name: 'Composio Integrations' })).toBeVisible({
await page.getByRole('tab', { name: 'Apps' }).click();
await expect(page.getByRole('heading', { name: 'Apps', exact: true })).toBeVisible({
timeout: 20_000,
});
}
async function assertSessionAlive(page: Page): Promise<void> {
// Phase 2: /skills → /connections
await expect
.poll(async () =>
page.evaluate(() => {
@@ -99,7 +101,7 @@ async function assertSessionAlive(page: Page): Promise<void> {
};
})
)
.toEqual({ hash: '#/skills', hasUser: true, hasToken: true });
.toEqual({ hash: '#/connections', hasUser: true, hasToken: true });
}
test.describe('Connector session guard matrix', () => {
@@ -143,7 +145,8 @@ test.describe('Connector session guard matrix', () => {
});
await page.reload();
await waitForAppReady(page);
await page.getByRole('tab', { name: 'Composio' }).click();
// Phase 2: "Composio" tab renamed to "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
await page.getByTestId('skill-install-composio-jira').click();
const dialog = page.getByRole('dialog', { name: /Jira/i });
await expect(dialog).toBeVisible();
@@ -156,14 +159,16 @@ test.describe('Connector session guard matrix', () => {
await seedToolkits('FAILED');
await page.reload();
await waitForAppReady(page);
await page.getByRole('tab', { name: 'Composio' }).click();
// Phase 2: "Composio" tab renamed to "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
await expect(page.getByTestId('skill-install-composio-discord')).toContainText('Discord');
await assertSessionAlive(page);
await seedToolkits('EXPIRED');
await page.reload();
await waitForAppReady(page);
await page.getByRole('tab', { name: 'Composio' }).click();
// Phase 2: "Composio" tab renamed to "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
await expect(page.getByTestId('skill-install-composio-github')).toContainText(
/Reconnect|GitHub/
);
+15 -16
View File
@@ -65,20 +65,15 @@ async function bootSkillsPage(page: Page, userId: string) {
localStorage.setItem('openhuman:walkthrough_completed', 'true');
localStorage.removeItem('openhuman:walkthrough_pending');
} catch {}
window.location.hash = '/skills';
// Phase 2: /skills → /connections
window.location.hash = '/connections';
});
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
await page.getByRole('tab', { name: 'Composio' }).click();
const heading = page.getByRole('heading', { name: 'Composio Integrations' });
if (!(await heading.isVisible().catch(() => false))) {
const connectionsButton = page.getByRole('button', { name: 'Connections' });
if (await connectionsButton.isVisible().catch(() => false)) {
await connectionsButton.click({ force: true });
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
}
}
// Phase 2: "Composio" tab renamed to "Apps"; heading is now "Apps" (skills.integrations)
await page.getByRole('tab', { name: 'Apps' }).click();
// Wait for the Apps tab grid to be visible — the h2 heading text is now "Apps"
const heading = page.getByRole('heading', { name: 'Apps', exact: true });
await expect(heading).toBeVisible({ timeout: 20_000 });
}
@@ -103,7 +98,8 @@ test.describe('Gmail Integration Flows', () => {
await page.reload();
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
await page.getByRole('tab', { name: 'Composio' }).click();
// Phase 2: "Composio" tab renamed to "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
await page.getByTestId('skill-install-composio-gmail').click();
await expect(page.getByRole('dialog', { name: /Connect Gmail/i })).toBeVisible();
@@ -129,13 +125,15 @@ test.describe('Gmail Integration Flows', () => {
await seedConnector('FAILED');
await page.reload();
await waitForAppReady(page);
await page.getByRole('tab', { name: 'Composio' }).click();
// Phase 2: "Composio" tab renamed to "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
await expect(page.getByTestId('skill-install-composio-gmail')).toContainText(CONNECTOR_NAME);
await seedConnector('EXPIRED');
await page.reload();
await waitForAppReady(page);
await page.getByRole('tab', { name: 'Composio' }).click();
// Phase 2: "Composio" tab renamed to "Apps"
await page.getByRole('tab', { name: 'Apps' }).click();
await expect(page.getByTestId(`skill-install-composio-${TOOLKIT_SLUG}`)).toContainText(
/Auth expired|Reconnect/i
);
@@ -143,8 +141,9 @@ test.describe('Gmail Integration Flows', () => {
test('execute and disconnect routes do not blank the skills page', async ({ page }) => {
await callCoreRpc('openhuman.composio_execute', { tool: ACTION, arguments: {} });
await page.getByRole('tab', { name: 'Composio' }).click();
await expect(page.getByRole('heading', { name: 'Composio Integrations' })).toBeVisible();
// Phase 2: "Composio" tab renamed to "Apps"; heading is now "Apps" (skills.integrations)
await page.getByRole('tab', { name: 'Apps' }).click();
await expect(page.getByRole('heading', { name: 'Apps', exact: true })).toBeVisible();
await callCoreRpc('openhuman.composio_delete_connection', { connection_id: CONNECTION_ID });
const requests = await getRequestLog();
@@ -8,30 +8,37 @@ import {
test.describe('Google Meet Connections tab', () => {
test.beforeEach(async ({ page }) => {
await bootAuthenticatedPage(page, 'pw-gmeet-connections-tab-user', '/skills?tab=meetings');
// Phase 2: /skills → /connections; Phase 2b: MeetingBotsCard moved from
// Tools tab to the new Talents tab (?tab=talents). Back-compat: ?tab=meetings
// alias still works and resolves to talents.
await bootAuthenticatedPage(page, 'pw-gmeet-connections-tab-user', '/connections?tab=talents');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
});
test('opens the dedicated tab and shows a one-field meeting link modal', async ({ page }) => {
test('opens the Talents tab and shows the meeting link modal', async ({ page }) => {
await expect
.poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 })
.toContain('/skills?tab=meetings');
.toContain('/connections');
await expect(page.getByRole('tab', { name: 'Google Meet', exact: true })).toHaveAttribute(
// Phase 2b: MeetingBotsCard moved to the Talents tab; there is no longer a
// "Google Meet" sub-tab. The PillTabBar "Talents" pill carries the
// aria-selected=true attribute when the Talents surface is active.
await expect(page.getByRole('tab', { name: 'Talents', exact: true })).toHaveAttribute(
'aria-selected',
'true'
);
await page.getByTestId('meeting-bots-banner').click();
// The modal has a Meeting link (url) field plus the "Your Name in This
// Meeting" (respondTo) text field added in #3555. It stays Google-Meet only
// — no other platforms.
const dialog = page.getByRole('dialog', { name: 'Send OpenHuman to a meeting' });
await expect(dialog).toBeVisible();
await expect(dialog.getByLabel('Meeting link')).toBeVisible();
await expect(dialog.locator('input[type="url"]')).toHaveCount(1);
await expect(dialog.locator('input[type="text"]')).toHaveCount(1);
await expect(dialog.getByText('Wake Phrase')).toHaveCount(0);
await expect(dialog.getByText('Display name')).toHaveCount(0);
await expect(dialog.getByText('Zoom')).toHaveCount(0);
await expect(dialog.getByText('Microsoft Teams')).toHaveCount(0);
});
@@ -34,7 +34,9 @@ test.describe('Guided tour gates', () => {
await waitForAppReady(page);
});
test('tour starts from home and can navigate forward to the skills step', async ({ page }) => {
test('tour starts from home and can navigate forward to the connections step', async ({
page,
}) => {
await armWalkthrough(page);
const panel = await tooltip(page);
@@ -48,8 +50,11 @@ test.describe('Guided tour gates', () => {
await expect.poll(async () => page.evaluate(() => window.location.hash)).toContain('/chat');
await expect(page.locator('[data-walkthrough="chat-agent-panel"]')).toBeVisible();
// Phase 2: step 4 navigates to /connections (was /skills)
await clickTourNext(page);
await expect.poll(async () => page.evaluate(() => window.location.hash)).toContain('/skills');
await expect
.poll(async () => page.evaluate(() => window.location.hash))
.toContain('/connections');
await expect(page.locator('[data-walkthrough="skills-grid"]')).toBeVisible();
});
@@ -8,13 +8,14 @@ import {
test.describe('Insights Dashboard', () => {
test('renders the memory workspace and actions toolbar', async ({ page }) => {
await bootAuthenticatedPage(page, 'pw-insights-user', '/intelligence');
// Phase 3: Memory moved from /activity (no Memory tab there anymore) to
// /settings/intelligence which renders the full Intelligence page including
// the Memory tab. The Memory tab is NOT dev-only in Intelligence.tsx (only
// "council" is gated), so no developer mode seeding is needed.
await bootAuthenticatedPage(page, 'pw-insights-user', '/settings/intelligence');
await waitForAppReady(page);
// /intelligence boots on the Tasks tab (default in Intelligence.tsx since
// #2998). The walkthrough portal can sit on top of the pill bar and the
// memory-workspace testid only mounts when tab=memory, so we dismiss the
// overlay and click the Memory pill before asserting the panel chrome.
await dismissWalkthroughIfPresent(page);
// /settings/intelligence defaults to the Tasks tab — click Memory pill.
await page.getByRole('tab', { name: 'Memory', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Memory', exact: true })).toBeVisible({
@@ -12,8 +12,30 @@ import {
type MemorySource = { id: string; kind: string; label: string; enabled: boolean };
/**
* Seeds developer mode via `persist:theme` localStorage so the Memory tab
* (dev-gated in the Activity page since Phase 3) is visible after boot.
* Must be called via `page.addInitScript` before navigation.
*/
async function seedDeveloperMode(page: Page): Promise<void> {
await page.addInitScript(() => {
try {
const raw = localStorage.getItem('persist:theme');
const parsed: Record<string, string> = raw ? (JSON.parse(raw) as Record<string, string>) : {};
parsed.developerMode = JSON.stringify(true);
localStorage.setItem('persist:theme', JSON.stringify(parsed));
} catch {}
});
}
async function openMemory(page: Page): Promise<void> {
await bootAuthenticatedPage(page, 'pw-intelligence-memory-ui', '/intelligence');
// Phase 3: Memory moved out of /activity entirely — it now lives at
// /settings/intelligence (the full Intelligence dev surface). The Memory tab
// there is not dev-gated (only "council" is), so no developer mode seeding
// is required for the tab itself; seedDeveloperMode is still called so any
// code that checks developerMode elsewhere behaves consistently.
await seedDeveloperMode(page);
await bootAuthenticatedPage(page, 'pw-intelligence-memory-ui', '/settings/intelligence');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
const memoryTab = page.getByRole('tab', { name: /^Memory$/ });
+14 -9
View File
@@ -243,7 +243,8 @@ async function seedLocalStorage(page: Page) {
}
async function navigateToMcpTab(page: Page) {
await page.goto('/#/skills?tab=mcp');
// Phase 2: /skills → /connections, ?tab=mcp → ?tab=tools (back-compat alias also works)
await page.goto('/#/connections?tab=tools');
await page.waitForSelector('#root', { state: 'visible', timeout: 20_000 });
await page.locator('input[type="search"]').waitFor({ state: 'visible', timeout: 10_000 });
await page.locator('table').waitFor({ state: 'visible', timeout: 10_000 });
@@ -317,17 +318,21 @@ test.describe('MCP Tab — Table View & Filtering', () => {
test('search filters both installed and registry servers', async ({ page }) => {
const search = page.locator('input[type="search"]');
await search.fill('notion');
// Wait for the table to reflect the filtered results rather than using a fixed delay
// Wait for a Notion row to appear AND for non-matching rows (e.g. "Memory
// Server") to disappear — the table re-renders asynchronously and a naive
// count() immediately after the first visible check can race against the
// previous state still being in the DOM.
await expect(
page.locator('table tbody tr', { has: page.locator('td:has-text("Notion")') })
).toBeVisible({ timeout: 5_000 });
const rows = page.locator('table tbody tr');
const count = await rows.count();
expect(count).toBeGreaterThanOrEqual(1);
for (let i = 0; i < count; i++) {
const name = await rows.nth(i).locator('td:first-child').innerText();
expect(name.toLowerCase()).toContain('notion');
}
await expect(
page.locator('table tbody tr', { has: page.locator('td:has-text("Memory Server")') })
).toHaveCount(0, { timeout: 5_000 });
// The positive (Notion row present) + negative (Memory Server gone) checks
// above already prove the filter works. Avoid iterating `td:first-child`
// per row — the #3480 registry redesign changed the column layout, and the
// table re-renders async (the old per-row loop raced + assumed name-first).
await expect(page.locator('table tbody tr')).not.toHaveCount(0);
});
test('no Smithery branding visible anywhere', async ({ page }) => {
@@ -10,7 +10,8 @@ interface PanelCheck {
const panels: PanelCheck[] = [
{ hash: '/settings', markers: ['Settings', 'Appearance', 'Notifications'] },
{ hash: '/settings/memory-data', markers: ['Memory', 'Data', 'Storage'] },
{ hash: '/intelligence', markers: ['Memory', 'Intelligence'] },
// Phase 3: /intelligence → /activity; Memory tab is dev-gated, test only always-visible content
{ hash: '/activity', markers: ['Tasks', 'Automations', 'Activity'] },
{ hash: '/settings/developer-options', markers: ['Developer', 'Debug', 'Advanced'] },
{
hash: '/settings/billing',
@@ -7,14 +7,22 @@ interface RouteCheck {
markers: string[];
}
// Phase 2/3/6 IA revamp:
// /skills → /connections (redirects; test new canonical route)
// /intelligence → /activity (redirects; test new canonical route)
// /human → /chat (redirects; already covered by /chat entry)
const routes: RouteCheck[] = [
{ hash: '/chat', markers: ['Threads', 'Chat', 'Message', 'New'] },
{ hash: '/skills', markers: ['Skills', 'Skill', 'Install', 'Browse'] },
// Connections page (was /skills) — tabs: Apps, Messaging, Tools, Explorer
{ hash: '/connections', markers: ['Apps', 'Messaging', 'Tools', 'Explorer'] },
{ hash: '/home', markers: ['Ask your assistant anything', 'Your device is connected'] },
{ hash: '/channels', markers: ['Channels', 'Connect', 'Telegram', 'Discord'] },
// /channels now redirects to /connections?tab=messaging
{ hash: '/channels', markers: ['Messaging', 'Connections', 'Telegram', 'Discord'] },
{ hash: '/notifications', markers: ['Notifications', 'Alerts', 'No alerts yet'] },
{ hash: '/rewards', markers: ['Rewards', 'Referral', 'Credits', 'Invite'] },
{ hash: '/settings', markers: ['Settings', 'Account', 'Billing', 'Advanced'] },
// Activity page (was /intelligence) — tabs: Tasks, Automations, Background activity
{ hash: '/activity', markers: ['Tasks', 'Automations', 'Background'] },
{ hash: '/home', markers: ['Ask your assistant anything', 'Your device is connected'] },
];
+26 -3
View File
@@ -2,21 +2,44 @@ import { expect, test } from '@playwright/test';
import { bootAuthenticatedPage, waitForAppReady } from '../helpers/core-rpc';
const routes = ['/home', '/human', '/chat', '/skills', '/intelligence', '/rewards', '/settings'];
interface RouteEntry {
route: string;
/** Expected hash after any redirect. Defaults to the route itself. */
expectedHash?: string;
}
// Phase 2/3/6 IA revamp routes.
// Back-compat redirects are included so the router redirect itself is tested.
// /human → /chat (Phase 6)
// /skills → /connections (Phase 2)
// /intelligence → /activity (Phase 3)
const ROUTES: RouteEntry[] = [
{ route: '/home' },
{ route: '/human', expectedHash: '/chat' }, // back-compat redirect
{ route: '/chat' },
{ route: '/connections' },
{ route: '/skills', expectedHash: '/connections' }, // back-compat redirect
{ route: '/activity' },
{ route: '/intelligence', expectedHash: '/activity' }, // back-compat redirect
{ route: '/rewards' },
{ route: '/settings' },
];
test.describe('Navigation', () => {
test.beforeEach(async ({ page }) => {
await bootAuthenticatedPage(page, 'pw-navigation-user');
});
for (const route of routes) {
for (const { route, expectedHash } of ROUTES) {
const landing = expectedHash ?? route;
test(`renders ${route}`, async ({ page }) => {
await page.goto(`/#${route}`);
await waitForAppReady(page);
// After redirects the hash should begin with the final landing path.
await expect
.poll(async () => page.evaluate(() => window.location.hash))
.toMatch(new RegExp(`^#${route.replace('/', '\\/')}`));
.toMatch(new RegExp(`^#${landing.replace('/', '\\/')}`));
await expect
.poll(async () => {
const text = await page.locator('#root').innerText();
@@ -59,10 +59,10 @@ test.describe('Settings - Advanced Config', () => {
test('renders the developer options route and its advanced entries', async ({ page }) => {
await gotoSettingsRoute(page, '/settings/developer-options');
await expect(page.getByRole('heading', { name: 'Advanced' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Developer & Diagnostics' })).toBeVisible();
await expect(page.getByTestId('settings-nav-ai')).toBeVisible();
await expect(page.getByTestId('settings-nav-composio')).toBeVisible();
await expect(page.getByTestId('settings-nav-about')).toBeVisible();
await expect(page.getByTestId('settings-nav-build-info')).toBeVisible();
});
test('persists notification routing settings through core RPC', async ({ page }) => {
@@ -27,13 +27,14 @@ test.describe('Settings - Channels & Permissions', () => {
});
test('allows switching default messaging channel', async ({ page }) => {
await page.goto('/#/skills');
// Phase 2: default messaging channel UI moved to /connections (Messaging tab)
await page.goto('/#/connections?tab=messaging');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
const channelsTab = page.getByRole('tab', { name: 'Channels', exact: true });
if (await channelsTab.isVisible().catch(() => false)) {
await channelsTab.click();
const messagingTab = page.getByRole('tab', { name: 'Messaging', exact: true });
if (await messagingTab.isVisible().catch(() => false)) {
await messagingTab.click();
}
await expect(page.getByText('Default Messaging Channel').last()).toBeVisible();
@@ -92,17 +92,19 @@ test.describe('Settings - Feature Preferences', () => {
await expect(page.getByText('Features', { exact: true })).toBeVisible();
await expect(page.getByTestId('settings-nav-screen-intelligence')).toBeVisible();
await expect(page.getByTestId('settings-nav-messaging')).toBeVisible();
// Phase 2: default messaging channel moved to /connections (Messaging tab);
// the settings/features nav no longer has a dedicated "messaging" entry.
await expect(page.getByTestId('settings-nav-notifications')).toBeVisible();
await expect(page.getByTestId('settings-nav-tools')).toBeVisible();
});
test('persists the default messaging channel through redux state', async ({ page }) => {
await openAuthenticatedRoute(page, 'pw-settings-default-channel', '/skills');
// Phase 2: default messaging channel moved to /connections (Messaging tab)
await openAuthenticatedRoute(page, 'pw-settings-default-channel', '/connections?tab=messaging');
const channelsTab = page.getByRole('tab', { name: 'Channels', exact: true });
if (await channelsTab.isVisible().catch(() => false)) {
await channelsTab.click();
const messagingTab = page.getByRole('tab', { name: 'Messaging', exact: true });
if (await messagingTab.isVisible().catch(() => false)) {
await messagingTab.click();
}
await expect(page.getByText('Default Messaging Channel').last()).toBeVisible();
@@ -24,15 +24,16 @@ test.describe('Skill discovery (UI + core RPC)', () => {
});
test('skills UI surface shows installed tools', async ({ page }) => {
await page.goto('/#/skills');
// /skills redirects to /connections (Phase 2 rename)
await page.goto('/#/connections');
await waitForAppReady(page);
const hash = await page.evaluate(() => window.location.hash);
expect(String(hash)).toContain('/skills');
expect(String(hash)).toContain('/connections');
const text = await page.locator('#root').innerText();
expect(
['Composio Integrations', 'Channels', 'Gmail', 'Notion', 'GitHub'].some(marker =>
['Composio Integrations', 'Apps', 'Messaging', 'Gmail', 'Notion', 'GitHub'].some(marker =>
text.includes(marker)
)
).toBe(true);
@@ -5,20 +5,20 @@ import { bootAuthenticatedPage, callCoreRpc, waitForAppReady } from '../helpers/
test.describe('Skill lifecycle smoke', () => {
test.beforeEach(async ({ page }, testInfo) => {
const testSlug = testInfo.title.toLowerCase().replace(/[^a-z0-9]+/g, '-');
await bootAuthenticatedPage(page, 'pw-skill-lifecycle-' + testSlug, '/skills');
// Phase 2: /skills redirected to /connections
await bootAuthenticatedPage(page, 'pw-skill-lifecycle-' + testSlug, '/connections');
});
test('skills page mounts and the skills_list RPC is reachable', async ({ page }) => {
test('connections page mounts and the workflows_list RPC is reachable', async ({ page }) => {
await waitForAppReady(page);
await expect
.poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 })
.toContain('/skills');
.toContain('/connections');
const text = await page.locator('#root').innerText();
// Connections page tab labels (IA revamp): Apps/Messaging/Tools/Explorer/Talents.
expect(
['Composio Integrations', 'Install', 'Available', 'Channels'].some(marker =>
text.includes(marker)
)
['Apps', 'Messaging', 'Tools', 'Explorer', 'Talents'].some(marker => text.includes(marker))
).toBe(true);
const rpcResult = await callCoreRpc<unknown>('openhuman.workflows_list', {});

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