mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(shell): root two-panel layout — unified sidebar + chat-as-home (#3751)
This commit is contained in:
+58
-33
@@ -13,12 +13,14 @@ import AppRoutes from './AppRoutes';
|
||||
import AppBackground from './components/AppBackground';
|
||||
import AppUpdatePrompt from './components/AppUpdatePrompt';
|
||||
import BootCheckGate from './components/BootCheckGate/BootCheckGate';
|
||||
import BottomTabBar from './components/BottomTabBar';
|
||||
import CommandProvider from './components/commands/CommandProvider';
|
||||
import ServiceBlockingGate from './components/daemon/ServiceBlockingGate';
|
||||
import DictationHotkeyManager from './components/DictationHotkeyManager';
|
||||
import ErrorFallbackScreen from './components/ErrorFallbackScreen';
|
||||
import KeyringConsentOverlay from './components/keyring/KeyringConsentOverlay';
|
||||
import AppSidebar from './components/layout/shell/AppSidebar';
|
||||
import RootShellLayout from './components/layout/shell/RootShellLayout';
|
||||
import { SidebarSlotProvider } from './components/layout/shell/SidebarSlot';
|
||||
import LocalAIDownloadSnackbar from './components/LocalAIDownloadSnackbar';
|
||||
import SecretPromptDialog from './components/mcp-setup/SecretPromptDialog';
|
||||
import OpenhumanLinkModal from './components/OpenhumanLinkModal';
|
||||
@@ -50,12 +52,13 @@ import {
|
||||
stopInternetStatusListener,
|
||||
} from './services/internetStatusListener';
|
||||
import {
|
||||
hideWebviewAccount,
|
||||
startWebviewAccountService,
|
||||
stopWebviewAccountService,
|
||||
} from './services/webviewAccountService';
|
||||
import { persistor, store } from './store';
|
||||
import { useAppSelector } from './store/hooks';
|
||||
import { isAccountsFullscreen } from './utils/accountsFullscreen';
|
||||
import { AGENT_ACCOUNT_ID } from './utils/accountsFullscreen';
|
||||
import { DEV_FORCE_ONBOARDING } from './utils/config';
|
||||
|
||||
// Attach the `webview:event` listener at app boot so background recipe
|
||||
@@ -162,11 +165,6 @@ function AppShellDesktop() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { snapshot, isBootstrapping } = useCoreState();
|
||||
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
|
||||
// On /accounts, only the agent view keeps the tab bar + its reserved
|
||||
// bottom padding. Any other selected "app" (e.g. WhatsApp) takes the
|
||||
// full viewport so the embedded webview goes edge-to-edge.
|
||||
const fullscreen = isAccountsFullscreen(location.pathname, activeAccountId);
|
||||
const onOnboardingRoute = location.pathname.startsWith('/onboarding');
|
||||
const onboardingPending =
|
||||
!!snapshot.sessionToken && (DEV_FORCE_ONBOARDING || !snapshot.onboardingCompleted);
|
||||
@@ -183,9 +181,9 @@ function AppShellDesktop() {
|
||||
navigate('/onboarding', { replace: true });
|
||||
} else if (!onboardingPending && onOnboardingRoute) {
|
||||
console.debug(
|
||||
`[onboarding-gate] redirecting ${location.pathname} -> /home (onboarding complete)`
|
||||
`[onboarding-gate] redirecting ${location.pathname} -> /chat (onboarding complete)`
|
||||
);
|
||||
navigate('/home', { replace: true });
|
||||
navigate('/chat', { replace: true });
|
||||
}
|
||||
}, [
|
||||
isBootstrapping,
|
||||
@@ -201,6 +199,17 @@ function AppShellDesktop() {
|
||||
trackPageView(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
// Hide the active connected-app webview when we navigate away from the chat
|
||||
// surface. The native CEF webview composites above the HTML, so without this
|
||||
// it lingers on top of the newly-routed page until the user returns.
|
||||
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
|
||||
useEffect(() => {
|
||||
const onChat = location.pathname === '/chat' || location.pathname.startsWith('/chat/');
|
||||
if (!onChat && activeAccountId && activeAccountId !== AGENT_ACCOUNT_ID) {
|
||||
void hideWebviewAccount(activeAccountId);
|
||||
}
|
||||
}, [location.pathname, activeAccountId]);
|
||||
|
||||
// Sync the notch indicator to the persisted always-on listening state once
|
||||
// the core is ready (once per boot). Extracted to a hook so it's testable.
|
||||
useNotchBootSync(isBootstrapping);
|
||||
@@ -214,32 +223,48 @@ function AppShellDesktop() {
|
||||
}
|
||||
}, [location.pathname, navType]);
|
||||
|
||||
return (
|
||||
<div className="relative h-screen flex flex-col overflow-hidden">
|
||||
<AppBackground />
|
||||
<div className="relative z-10 flex-1 flex flex-col overflow-hidden">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`flex-1 overflow-y-auto ${fullscreen || onOnboardingRoute ? '' : 'pb-16'}`}>
|
||||
<GlobalUpsellBanner />
|
||||
<AppRoutes />
|
||||
</div>
|
||||
{!onOnboardingRoute && <BottomTabBar />}
|
||||
</div>
|
||||
<OpenhumanLinkModal />
|
||||
{/* Hidden Remotion-driven producer for the Meet camera. Mounts a
|
||||
640×480 JPEG frame stream to the Rust frame bus while a meet
|
||||
call is active; idle no-op otherwise. See
|
||||
features/meet/MascotFrameProducer.tsx. */}
|
||||
<MascotFrameProducer />
|
||||
{/* Post-onboarding Joyride walkthrough — mounted here (outside routes) so
|
||||
it persists across tab navigations. Joyride targets span Home + BottomTabBar
|
||||
tabs so it must stay mounted while the user moves between routes. */}
|
||||
{!isBootstrapping && !onOnboardingRoute && (
|
||||
<AppWalkthrough onboarded={!!snapshot.onboardingCompleted} />
|
||||
)}
|
||||
// Routes that own the full viewport with no app chrome: the public
|
||||
// welcome/login screens, the onboarding stepper, and any pre-auth state.
|
||||
// Everything else renders inside the root two-pane shell (sidebar + main).
|
||||
const token = snapshot.sessionToken;
|
||||
const onHiddenChromePath = ['/', '/login'].some(
|
||||
path => location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||
);
|
||||
const chromeless = !token || onOnboardingRoute || onHiddenChromePath;
|
||||
|
||||
const content = (
|
||||
<div ref={scrollRef} className="relative h-full overflow-y-auto">
|
||||
<GlobalUpsellBanner />
|
||||
<AppRoutes />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarSlotProvider>
|
||||
<div className="relative h-screen flex flex-col overflow-hidden">
|
||||
<AppBackground />
|
||||
<div className="relative z-10 flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{chromeless ? (
|
||||
content
|
||||
) : (
|
||||
<RootShellLayout sidebar={<AppSidebar />}>{content}</RootShellLayout>
|
||||
)}
|
||||
</div>
|
||||
<OpenhumanLinkModal />
|
||||
{/* Hidden Remotion-driven producer for the Meet camera. Mounts a
|
||||
640×480 JPEG frame stream to the Rust frame bus while a meet
|
||||
call is active; idle no-op otherwise. See
|
||||
features/meet/MascotFrameProducer.tsx. */}
|
||||
<MascotFrameProducer />
|
||||
{/* Post-onboarding Joyride walkthrough — mounted here (outside routes) so
|
||||
it persists across tab navigations. Joyride targets span Home + the
|
||||
sidebar nav so it must stay mounted while the user moves between routes. */}
|
||||
{!isBootstrapping && !onOnboardingRoute && (
|
||||
<AppWalkthrough onboarded={!!snapshot.onboardingCompleted} />
|
||||
)}
|
||||
</div>
|
||||
</SidebarSlotProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -9,7 +9,6 @@ import { getIsMobile } from './lib/platform';
|
||||
import Accounts from './pages/Accounts';
|
||||
import Brain from './pages/Brain';
|
||||
import AgentInsightsPreview from './pages/dev/AgentInsightsPreview';
|
||||
import Home from './pages/Home';
|
||||
import Invites from './pages/Invites';
|
||||
import Notifications from './pages/Notifications';
|
||||
import Onboarding from './pages/onboarding/Onboarding';
|
||||
@@ -55,14 +54,9 @@ const AppRoutes = () => {
|
||||
/>
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/home"
|
||||
element={
|
||||
<ProtectedRoute requireAuth={true}>
|
||||
<Home />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Home is merged into the unified chat surface — /home redirects to /chat
|
||||
(the chat's empty "new window" state is the former Home greeting). */}
|
||||
<Route path="/home" element={<Navigate to="/chat" replace />} />
|
||||
|
||||
{/* Human — first-class destination again (restored after the IA Phase 6
|
||||
merge into Assistant). Renders the Human/mascot surface. iOS serves
|
||||
|
||||
@@ -68,7 +68,7 @@ vi.mock('../components/BootCheckGate/BootCheckGate', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock('../components/MeshGradient', () => ({ default: () => null }));
|
||||
vi.mock('../components/BottomTabBar', () => ({ default: () => null }));
|
||||
vi.mock('../components/layout/shell/AppSidebar', () => ({ default: () => null }));
|
||||
vi.mock('../components/AppUpdatePrompt', () => ({ default: () => null }));
|
||||
vi.mock('../components/LocalAIDownloadSnackbar', () => ({ default: () => null }));
|
||||
vi.mock('../components/daemon/ServiceBlockingGate', () => ({
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
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';
|
||||
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';
|
||||
|
||||
// ── SVG icons, keyed by tab id ────────────────────────────────────────────────
|
||||
|
||||
function TabIcon({ id }: { id: string }) {
|
||||
const cls = 'w-4 h-4';
|
||||
switch (id) {
|
||||
case 'home':
|
||||
return (
|
||||
<svg className={cls} 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={cls} 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={cls} 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={cls} 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={cls} 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={cls} 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>
|
||||
);
|
||||
case 'brain':
|
||||
// Two symmetric lobes — reads clearly as a brain.
|
||||
return (
|
||||
<svg className={cls} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M9.5 2A2.5 2.5 0 0112 4.5v15a2.5 2.5 0 01-4.96.44 2.5 2.5 0 01-2.96-3.08 3 3 0 01-.34-5.58 2.5 2.5 0 011.32-4.24 2.5 2.5 0 011.98-3A2.5 2.5 0 019.5 2z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M14.5 2A2.5 2.5 0 0012 4.5v15a2.5 2.5 0 004.96.44 2.5 2.5 0 002.96-3.08 3 3 0 00.34-5.58 2.5 2.5 0 00-1.32-4.24 2.5 2.5 0 00-1.98-3A2.5 2.5 0 0014.5 2z"
|
||||
/>
|
||||
</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();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
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));
|
||||
const companionActive = useAppSelector(selectCompanionSessionActive);
|
||||
// `state.theme` is undefined in some test fixtures that build a minimal
|
||||
// store without the theme slice; default to the historical 'hover' behavior
|
||||
// so an absent theme branch can't crash the bar.
|
||||
const tabBarLabels = useAppSelector(state => state.theme?.tabBarLabels ?? 'hover');
|
||||
const labelsAlwaysVisible = tabBarLabels === 'always';
|
||||
|
||||
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 (
|
||||
!token ||
|
||||
hiddenPaths.some(path => location.pathname === path || location.pathname.startsWith(`${path}/`))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// On /accounts we want as much real estate as possible for the embedded
|
||||
// webview — but *only* when a real account (WhatsApp, …) is selected.
|
||||
// The Agent entry keeps the tab bar visible so chatting with the agent
|
||||
// feels like a normal page. A thin hover strip along the bottom lets
|
||||
// the user reveal the bar manually even in fullscreen mode.
|
||||
const fullscreen = isAccountsFullscreen(location.pathname, activeAccountId);
|
||||
const collapsed = fullscreen && !revealed;
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/chat') return location.pathname.startsWith('/chat');
|
||||
// Every /settings/* page lives in the two-pane settings layout — the
|
||||
// Settings tab is active for all of them. (The old cron-jobs/messaging
|
||||
// exclusions covered dedicated tabs that no longer exist.)
|
||||
if (path === '/settings')
|
||||
return location.pathname === '/settings' || location.pathname.startsWith('/settings/');
|
||||
if (path === '/home') return location.pathname === '/home';
|
||||
return location.pathname === path;
|
||||
};
|
||||
|
||||
const activeTab = tabs.find(tab => isActive(tab.path));
|
||||
|
||||
const handleTabClick = (tab: (typeof tabs)[number], active: boolean) => {
|
||||
if (!active) {
|
||||
trackEvent('tab_bar_change', {
|
||||
from_tab: activeTab?.id ?? 'unknown',
|
||||
to_tab: tab.id,
|
||||
from_path: location.pathname,
|
||||
to_path: tab.path,
|
||||
});
|
||||
}
|
||||
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 });
|
||||
};
|
||||
|
||||
// One regular pill tab.
|
||||
//
|
||||
// When labels are always visible (theme setting), every labelled tab is given
|
||||
// the SAME fixed width so the row stays symmetric. In the default hover mode
|
||||
// the label still expands on hover (no fixed width) — unchanged behaviour.
|
||||
const renderTab = (tab: (typeof tabs)[number]) => {
|
||||
const active = isActive(tab.path);
|
||||
const showBadge = tab.id === 'notifications' && unreadCount > 0;
|
||||
const showCompanionDot = tab.id === 'settings' && companionActive;
|
||||
const fixedWidth = labelsAlwaysVisible;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
data-walkthrough={tab.walkthroughAttr}
|
||||
onClick={() => handleTabClick(tab, active)}
|
||||
title={tab.label}
|
||||
className={`group relative flex items-center rounded-sm text-sm transition-colors duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] cursor-pointer ${
|
||||
fixedWidth ? 'w-32 justify-center px-2 py-2' : 'px-2 py-2'
|
||||
} ${
|
||||
active
|
||||
? 'bg-white dark:bg-neutral-800 text-stone-900 dark:text-neutral-100 font-semibold shadow-sm'
|
||||
: 'bg-transparent text-stone-500 dark:text-neutral-400 hover:bg-stone-300/50 dark:hover:bg-neutral-800/60 hover:text-stone-700 dark:hover:text-neutral-200'
|
||||
}`}
|
||||
aria-label={
|
||||
tab.id === 'notifications' && unreadCount > 0
|
||||
? `${tab.label} (${unreadCount} ${t('alerts.unread')})`
|
||||
: tab.label
|
||||
}>
|
||||
<span className="relative inline-flex flex-shrink-0">
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
{showCompanionDot && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-blue-500 animate-pulse" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-0 overflow-hidden whitespace-nowrap transition-[max-width,margin-left,opacity] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${
|
||||
active || labelsAlwaysVisible
|
||||
? `${fixedWidth ? 'truncate ' : ''}max-w-[160px] ml-2 opacity-100`
|
||||
: 'max-w-0 ml-0 opacity-0 group-hover:max-w-[160px] group-hover:ml-2 group-hover:opacity-100 group-focus-visible:max-w-[160px] group-focus-visible:ml-2 group-focus-visible:opacity-100'
|
||||
}`}>
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// All tabs render as uniform pill buttons in a single row; the avatar
|
||||
// stays pinned to the far-right behind a divider.
|
||||
|
||||
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
|
||||
// such as Settings SaveBar. Only the <nav> pill re-enables hits.
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-50">
|
||||
{/* Hover strip — only matters when collapsed; provides a 12px bottom
|
||||
edge the user can mouse into to reveal the bar again. */}
|
||||
{collapsed && (
|
||||
<div
|
||||
className="pointer-events-auto absolute inset-x-0 bottom-0 h-3"
|
||||
onMouseEnter={() => setRevealed(true)}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`pointer-events-none flex justify-center px-4 pb-4 pt-2 transition-transform duration-300 ease-out ${
|
||||
collapsed ? 'translate-y-[calc(100%+8px)]' : 'translate-y-0'
|
||||
}`}
|
||||
onMouseLeave={() => setRevealed(false)}
|
||||
onFocus={() => setRevealed(true)}
|
||||
onBlur={e => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setRevealed(false);
|
||||
}}>
|
||||
<nav className="pointer-events-auto inline-flex items-center gap-1 rounded-sm border border-stone-300 dark:border-neutral-700 bg-stone-200 dark:bg-neutral-900 shadow-soft px-1 py-1">
|
||||
{tabs.map(tab => renderTab(tab))}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default BottomTabBar;
|
||||
@@ -75,14 +75,19 @@ const ConnectionIndicator = ({
|
||||
}
|
||||
})();
|
||||
|
||||
// Simplified two-state label (the dot colour still reflects the nuanced
|
||||
// connecting/reconnecting states).
|
||||
const isConnected = overrideStatus ? overrideStatus === 'connected' : blocking === 'ok';
|
||||
const label = isConnected
|
||||
? t('app.connectionIndicator.connected')
|
||||
: t('app.connectionIndicator.disconnected');
|
||||
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-stone-50 dark:bg-neutral-800/60 border border-stone-200 dark:border-neutral-800">
|
||||
<div
|
||||
className={`w-2 h-2 ${config.color} rounded-full ${config.pulse ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
<span className={`text-xs font-medium ${config.textColor}`}>{config.text}</span>
|
||||
</div>
|
||||
<div className={`inline-flex items-center gap-1.5 ${className}`}>
|
||||
<div
|
||||
className={`w-2 h-2 ${config.color} rounded-full ${config.pulse ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
<span className={`text-[10px] font-medium ${config.textColor}`}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import RouteLoadingScreen from './RouteLoadingScreen';
|
||||
* Default redirect based on auth + onboarding status.
|
||||
* - Not logged in → / (Welcome page)
|
||||
* - Logged in, onboarding not completed → /onboarding
|
||||
* - Logged in, onboarding completed → /home
|
||||
* - Logged in, onboarding completed → /chat (the unified home/chat surface)
|
||||
*/
|
||||
const DefaultRedirect = () => {
|
||||
const { isBootstrapping, snapshot } = useCoreState();
|
||||
@@ -43,7 +43,7 @@ const DefaultRedirect = () => {
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
|
||||
return <Navigate to="/home" replace />;
|
||||
return <Navigate to="/chat" replace />;
|
||||
};
|
||||
|
||||
export default DefaultRedirect;
|
||||
|
||||
@@ -5,7 +5,13 @@ import { setLocale } from '../store/localeSlice';
|
||||
// Listed roughly by speaker count (English first as the source-of-truth locale).
|
||||
// Labels are intentionally rendered in each locale's own script so the picker
|
||||
// is recognisable to a native speaker even before the rest of the UI rerenders.
|
||||
const LOCALE_OPTIONS: Array<{ value: Locale; flag: string; label: string }> = [
|
||||
interface LocaleOption {
|
||||
value: Locale;
|
||||
flag: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const LOCALE_OPTIONS: LocaleOption[] = [
|
||||
{ value: 'en', flag: '🇬🇧', label: 'English' },
|
||||
{ value: 'ko', flag: '🇰🇷', label: '한국어' },
|
||||
{ value: 'zh-CN', flag: '🇨🇳', label: '简体中文' },
|
||||
|
||||
@@ -1,367 +0,0 @@
|
||||
/**
|
||||
* Tests for BottomTabBar — verifies that:
|
||||
* - 6 tabs are rendered (no Rewards or Activity tab; Human restored; Chat is a regular tab)
|
||||
* - Chat tab is present (id 'chat', label 'Chat')
|
||||
* - Walkthrough attributes reflect the new ids (tab-connections)
|
||||
* - 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
|
||||
*/
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
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 themeReducer, { setTabBarLabels, type TabBarLabels } from '../../store/themeSlice';
|
||||
import BottomTabBar from '../BottomTabBar';
|
||||
|
||||
// ── Module-level mocks ─────────────────────────────────────────────────────
|
||||
|
||||
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' };
|
||||
});
|
||||
|
||||
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;
|
||||
tabBarLabels?: TabBarLabels;
|
||||
}
|
||||
|
||||
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,
|
||||
theme: themeReducer,
|
||||
},
|
||||
});
|
||||
store.dispatch(setAgentProfilesFromResponse(testProfiles));
|
||||
if (opts.companionSessionActive) {
|
||||
store.dispatch({
|
||||
type: 'companion/setSessionActive',
|
||||
payload: { active: true, sessionId: 'sess-test' },
|
||||
});
|
||||
}
|
||||
if (opts.tabBarLabels) {
|
||||
store.dispatch(setTabBarLabels(opts.tabBarLabels));
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
interface RenderOpts {
|
||||
hasToken?: boolean;
|
||||
companionSessionActive?: boolean;
|
||||
tokenValue?: string;
|
||||
currentUser?: unknown;
|
||||
tabBarLabels?: TabBarLabels;
|
||||
}
|
||||
|
||||
async function renderBottomTabBar(pathname = '/home', opts: RenderOpts | boolean = {}) {
|
||||
// Back-compat: previous callsites passed `hasToken` as the 2nd positional arg.
|
||||
const resolved: RenderOpts = typeof opts === 'boolean' ? { hasToken: opts } : opts;
|
||||
const hasToken = resolved.hasToken ?? true;
|
||||
const tokenValue = resolved.tokenValue ?? 'tok-test';
|
||||
const { useCoreState } = await import('../../providers/CoreStateProvider');
|
||||
vi.mocked(useCoreState).mockReturnValue({
|
||||
snapshot: {
|
||||
sessionToken: hasToken ? tokenValue : null,
|
||||
auth: { isAuthenticated: true, userId: 'u1', user: null, profileId: null },
|
||||
currentUser: resolved.currentUser ?? null,
|
||||
onboardingCompleted: true,
|
||||
chatOnboardingCompleted: true,
|
||||
analyticsEnabled: false,
|
||||
localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null },
|
||||
keyringStatus: {
|
||||
available: true,
|
||||
failureReason: null,
|
||||
activeMode: 'os_keyring',
|
||||
backendName: 'os',
|
||||
},
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
},
|
||||
isBootstrapping: false,
|
||||
isReady: true,
|
||||
teams: [],
|
||||
teamMembersById: {},
|
||||
teamInvitesById: {},
|
||||
setOnboardingCompletedFlag: vi.fn(),
|
||||
setOnboardingTasks: vi.fn(),
|
||||
refreshSnapshot: vi.fn(),
|
||||
} as never);
|
||||
|
||||
const store = buildStore({
|
||||
companionSessionActive: resolved.companionSessionActive,
|
||||
tabBarLabels: resolved.tabBarLabels,
|
||||
});
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={[pathname]}>
|
||||
<BottomTabBar />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('BottomTabBar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
agentProfilesApiMock.select.mockResolvedValue(testProfiles);
|
||||
});
|
||||
|
||||
it('renders exactly 6 regular tab buttons (Chat is a regular tab)', async () => {
|
||||
await renderBottomTabBar('/home');
|
||||
const nav = document.querySelector('nav');
|
||||
const navButtons = nav?.querySelectorAll('button:not([aria-haspopup])');
|
||||
expect(navButtons).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('gives every labelled tab a fixed width when labels are always visible', async () => {
|
||||
await renderBottomTabBar('/home', { tabBarLabels: 'always' });
|
||||
// With the "always show labels" theme setting, each regular tab is given the
|
||||
// same fixed width (w-32) and its label is shown with a truncating class so
|
||||
// the row stays symmetric — this exercises the `labelsAlwaysVisible` branch.
|
||||
const humanBtn = screen.getByRole('button', { name: 'Human' });
|
||||
expect(humanBtn).toHaveClass('w-32');
|
||||
expect(humanBtn.querySelector('.truncate')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the Chat tab with data-walkthrough="tab-chat"', async () => {
|
||||
await renderBottomTabBar('/home');
|
||||
const chatBtn = screen.getByRole('button', { name: 'Chat' });
|
||||
expect(chatBtn).toBeInTheDocument();
|
||||
expect(chatBtn).toHaveAttribute('data-walkthrough', 'tab-chat');
|
||||
});
|
||||
|
||||
it('navigates to /chat and tracks the change when the Chat tab is clicked', async () => {
|
||||
const { trackEvent } = await import('../../services/analytics');
|
||||
await renderBottomTabBar('/home');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Chat' }));
|
||||
|
||||
expect(trackEvent).toHaveBeenCalledWith('tab_bar_change', {
|
||||
from_tab: 'home',
|
||||
to_tab: 'chat',
|
||||
from_path: '/home',
|
||||
to_path: '/chat',
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT render a Rewards tab', async () => {
|
||||
await renderBottomTabBar('/home');
|
||||
expect(screen.queryByRole('button', { name: 'Rewards' })).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the Human tab (restored as a first-class entry)', async () => {
|
||||
await renderBottomTabBar('/home');
|
||||
const humanBtn = screen.getByRole('button', { name: 'Human' });
|
||||
expect(humanBtn).toBeInTheDocument();
|
||||
expect(humanBtn).toHaveAttribute('data-walkthrough', 'tab-human');
|
||||
});
|
||||
|
||||
it('navigates to /human and tracks the change when the Human tab is clicked', async () => {
|
||||
const { trackEvent } = await import('../../services/analytics');
|
||||
await renderBottomTabBar('/home');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Human' }));
|
||||
|
||||
expect(trackEvent).toHaveBeenCalledWith('tab_bar_change', {
|
||||
from_tab: 'home',
|
||||
to_tab: 'human',
|
||||
from_path: '/home',
|
||||
to_path: '/human',
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT render an Activity tab', async () => {
|
||||
await renderBottomTabBar('/home');
|
||||
expect(screen.queryByRole('button', { name: 'Activity' })).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the Brain tab in the regular row with data-walkthrough="tab-brain"', async () => {
|
||||
await renderBottomTabBar('/home');
|
||||
const brainBtn = screen.getByRole('button', { name: 'Brain' });
|
||||
expect(brainBtn).toBeInTheDocument();
|
||||
expect(brainBtn).toHaveAttribute('data-walkthrough', 'tab-brain');
|
||||
});
|
||||
|
||||
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 Settings tab with data-walkthrough="tab-settings"', async () => {
|
||||
await renderBottomTabBar('/home');
|
||||
const settingsBtn = screen.getByRole('button', { name: 'Settings' });
|
||||
expect(settingsBtn).toHaveAttribute('data-walkthrough', 'tab-settings');
|
||||
});
|
||||
|
||||
it('returns null when there is no session token', async () => {
|
||||
const { container } = await renderBottomTabBar('/home', { hasToken: false });
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
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' });
|
||||
const dot = settingsBtn.querySelector('.animate-pulse.bg-blue-500');
|
||||
expect(dot).not.toBeNull();
|
||||
// And not on a non-Settings tab.
|
||||
const homeBtn = screen.getByRole('button', { name: 'Home' });
|
||||
expect(homeBtn.querySelector('.animate-pulse.bg-blue-500')).toBeNull();
|
||||
void container;
|
||||
});
|
||||
|
||||
it('returns null on the "/" path even with a session token', async () => {
|
||||
const { container } = await renderBottomTabBar('/');
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('uses pointer-events-none on the full-width shell so side areas do not block clicks', async () => {
|
||||
const { container } = await renderBottomTabBar('/home');
|
||||
const shell = container.firstElementChild;
|
||||
expect(shell).toHaveClass('pointer-events-none');
|
||||
expect(shell?.querySelector('nav')).toHaveClass('pointer-events-auto');
|
||||
});
|
||||
|
||||
it('tracks tab changes when a different (regular row) tab is clicked', async () => {
|
||||
const { trackEvent } = await import('../../services/analytics');
|
||||
await renderBottomTabBar('/home');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Brain' }));
|
||||
|
||||
expect(trackEvent).toHaveBeenCalledWith('tab_bar_change', {
|
||||
from_tab: 'home',
|
||||
to_tab: 'brain',
|
||||
from_path: '/home',
|
||||
to_path: '/brain',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not track when the active tab is clicked again', async () => {
|
||||
const { trackEvent } = await import('../../services/analytics');
|
||||
await renderBottomTabBar('/home');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Home' }));
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import ConnectionIndicator from '../ConnectionIndicator';
|
||||
describe('ConnectionIndicator', () => {
|
||||
it('renders connected state with override prop', () => {
|
||||
renderWithProviders(<ConnectionIndicator status="connected" />);
|
||||
expect(screen.getByText(/Connected to OpenHuman AI/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders disconnected state', () => {
|
||||
@@ -17,13 +17,13 @@ describe('ConnectionIndicator', () => {
|
||||
|
||||
it('renders connecting state', () => {
|
||||
renderWithProviders(<ConnectionIndicator status="connecting" />);
|
||||
expect(screen.getByText('Connecting')).toBeInTheDocument();
|
||||
expect(screen.getByText('Disconnected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders as a pill badge', () => {
|
||||
renderWithProviders(<ConnectionIndicator status="connected" />);
|
||||
// The indicator renders as an inline pill — status text is visible
|
||||
expect(screen.getByText(/Connected to OpenHuman AI/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to connectivity store when no override', () => {
|
||||
@@ -31,7 +31,7 @@ describe('ConnectionIndicator', () => {
|
||||
// backend connecting → blocking = backend-only → "Reconnecting…"
|
||||
// (#1527: split status; default reflects boot-time pre-socket state.)
|
||||
renderWithProviders(<ConnectionIndicator />);
|
||||
expect(screen.getByText(/Reconnecting|Connecting/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Disconnected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- Store-driven branches (lines 43, 50, 57, 67) ----
|
||||
@@ -47,7 +47,7 @@ describe('ConnectionIndicator', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(screen.getByText(/Connected to OpenHuman AI/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Offline" when blocking=internet-offline (line 50)', () => {
|
||||
@@ -61,7 +61,7 @@ describe('ConnectionIndicator', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(screen.getByText('Offline')).toBeInTheDocument();
|
||||
expect(screen.getByText('Disconnected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Core offline" when blocking=core-unreachable (line 57)', () => {
|
||||
@@ -75,7 +75,7 @@ describe('ConnectionIndicator', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(screen.getByText('Core offline')).toBeInTheDocument();
|
||||
expect(screen.getByText('Disconnected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Reconnecting…" when blocking=backend-only and socket is disconnected (line 67)', () => {
|
||||
@@ -90,7 +90,7 @@ describe('ConnectionIndicator', () => {
|
||||
socket: { byUser: {} },
|
||||
},
|
||||
});
|
||||
expect(screen.getByText('Reconnecting…')).toBeInTheDocument();
|
||||
expect(screen.getByText('Disconnected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Connecting" when blocking=backend-only and legacy socket status is connecting (line 67)', () => {
|
||||
@@ -106,6 +106,6 @@ describe('ConnectionIndicator', () => {
|
||||
socket: { byUser: { __pending__: { status: 'connecting', socketId: null } } },
|
||||
},
|
||||
});
|
||||
expect(screen.getByText(/Connecting|Reconnecting/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Disconnected')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ function renderRedirect(initialEntry = '*') {
|
||||
<Route path="/" element={<div>Welcome</div>} />
|
||||
<Route path="/onboarding" element={<div>Onboarding</div>} />
|
||||
<Route path="/home" element={<div>Home</div>} />
|
||||
<Route path="/chat" element={<div>Chat</div>} />
|
||||
<Route path="*" element={<DefaultRedirect />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
@@ -96,7 +97,7 @@ describe('DefaultRedirect', () => {
|
||||
expect(screen.getByText('Onboarding')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to /home for a returning user who already completed onboarding', () => {
|
||||
it('redirects to /chat for a returning user who already completed onboarding', () => {
|
||||
mockUseCoreState.mockReturnValue({
|
||||
isBootstrapping: false,
|
||||
snapshot: {
|
||||
@@ -108,6 +109,6 @@ describe('DefaultRedirect', () => {
|
||||
|
||||
renderRedirect();
|
||||
|
||||
expect(screen.getByText('Home')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chat')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,14 +132,13 @@ const WebviewHost = ({ accountId, provider }: WebviewHostProps) => {
|
||||
const measureAndSync = () => {
|
||||
if (!el || cancelled) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
// Inset the native webview by the container's border-radius so the
|
||||
// rounded HTML border is visible around the edges.
|
||||
const inset = 8;
|
||||
// The native webview fills the placeholder edge-to-edge (no inset) so the
|
||||
// embedded app occupies the full main content area.
|
||||
const bounds = {
|
||||
x: Math.round(rect.left + inset),
|
||||
y: Math.round(rect.top + inset),
|
||||
width: Math.max(1, Math.round(rect.width - inset * 2)),
|
||||
height: Math.max(1, Math.round(rect.height - inset * 2)),
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.max(1, Math.round(rect.width)),
|
||||
height: Math.max(1, Math.round(rect.height)),
|
||||
};
|
||||
const last = lastBoundsRef.current;
|
||||
const unchanged =
|
||||
@@ -195,7 +194,7 @@ const WebviewHost = ({ accountId, provider }: WebviewHostProps) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative h-full w-full overflow-hidden rounded-2xl border border-stone-200 dark:border-neutral-800/70 bg-stone-100 dark:bg-neutral-800 shadow-soft"
|
||||
className="relative h-full w-full overflow-hidden bg-stone-100 dark:bg-neutral-800"
|
||||
aria-label={`webview host for account ${accountId}`}>
|
||||
{/* Branded placeholder + (optional) loading overlay collapsed into a
|
||||
single absolute container so we never paint two stacked / offset
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import debugFactory from 'debug';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useUsageState } from '../../hooks/useUsageState';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { applyOpenRouterFreeModels } from '../../services/api/openrouterFreeModels';
|
||||
import { restartCoreProcess } from '../../services/coreProcessControl';
|
||||
import { selectBlockingState } from '../../store/connectivitySelectors';
|
||||
import { useAppSelector } from '../../store/hooks';
|
||||
import { resolveUserName } from '../../utils/userName';
|
||||
import { DiscordBanner, PromotionalCreditsBanner, UsageLimitBanner } from '../home/HomeBanners';
|
||||
|
||||
const debug = debugFactory('chat:new-window-hero');
|
||||
|
||||
/**
|
||||
* Hero shown above the composer in the chat "new window" (empty thread) state —
|
||||
* the merged Home surface. Mirrors the former Home card (greeting, connection
|
||||
* status, version + light/dark toggle, banners), but drops the "Ask Assistant"
|
||||
* CTA: the composer directly below is the call to action now. The
|
||||
* core-unreachable recovery button is preserved since the composer is disabled
|
||||
* while the core is down.
|
||||
*/
|
||||
export default function ChatNewWindowHero() {
|
||||
const { t } = useT();
|
||||
const { user } = useUser();
|
||||
const { shouldShowBudgetCompletedMessage } = useUsageState();
|
||||
|
||||
const userName = resolveUserName(user).split(' ')[0];
|
||||
const promoCredits = user?.usage?.promotionBalanceUsd ?? 0;
|
||||
const isFreeTier =
|
||||
user?.subscription?.plan === 'FREE' || !user?.subscription?.hasActiveSubscription;
|
||||
const showPromoBanner = isFreeTier && promoCredits > 0.01;
|
||||
|
||||
const blocking = useAppSelector(selectBlockingState);
|
||||
|
||||
const [isRestartingCore, setIsRestartingCore] = useState(false);
|
||||
const [restartError, setRestartError] = useState<string | null>(null);
|
||||
const [openRouterStatus, setOpenRouterStatus] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||
|
||||
const welcomeVariants = useMemo(
|
||||
() => [
|
||||
t('chat.newWindowWelcome1').replace('{name}', userName),
|
||||
t('chat.newWindowWelcome2').replace('{name}', userName),
|
||||
t('chat.newWindowWelcome3').replace('{name}', userName),
|
||||
],
|
||||
[t, userName]
|
||||
);
|
||||
const [welcomeVariantIndex, setWelcomeVariantIndex] = useState(0);
|
||||
const [typedWelcome, setTypedWelcome] = useState('');
|
||||
const [isDeletingWelcome, setIsDeletingWelcome] = useState(false);
|
||||
|
||||
const statusCopy = {
|
||||
ok: t('home.statusOk'),
|
||||
'backend-only': t('home.statusBackendOnly'),
|
||||
'core-unreachable': t('home.statusCoreUnreachable'),
|
||||
'internet-offline': t('home.statusInternetOffline'),
|
||||
}[blocking];
|
||||
|
||||
const handleRestartCore = async () => {
|
||||
setIsRestartingCore(true);
|
||||
setRestartError(null);
|
||||
try {
|
||||
await restartCoreProcess();
|
||||
} catch (err) {
|
||||
setRestartError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsRestartingCore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseOpenRouterFree = async () => {
|
||||
setOpenRouterStatus('saving');
|
||||
try {
|
||||
await applyOpenRouterFreeModels();
|
||||
setOpenRouterStatus('idle');
|
||||
} catch (err) {
|
||||
debug('applyOpenRouterFreeModels failed: %o', err);
|
||||
setOpenRouterStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
// Typewriter cycle — identical cadence to the former Home greeting.
|
||||
useEffect(() => {
|
||||
const activeVariant = welcomeVariants[welcomeVariantIndex] ?? '';
|
||||
const isFullyTyped = typedWelcome === activeVariant;
|
||||
const isFullyDeleted = typedWelcome.length === 0;
|
||||
|
||||
const delay = isDeletingWelcome
|
||||
? 36
|
||||
: isFullyTyped
|
||||
? 1400
|
||||
: typedWelcome.length === 0
|
||||
? 250
|
||||
: 55;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (!isDeletingWelcome) {
|
||||
if (isFullyTyped) {
|
||||
setIsDeletingWelcome(true);
|
||||
return;
|
||||
}
|
||||
setTypedWelcome(activeVariant.slice(0, typedWelcome.length + 1));
|
||||
return;
|
||||
}
|
||||
if (!isFullyDeleted) {
|
||||
setTypedWelcome(activeVariant.slice(0, typedWelcome.length - 1));
|
||||
return;
|
||||
}
|
||||
setIsDeletingWelcome(false);
|
||||
setWelcomeVariantIndex(current => (current + 1) % welcomeVariants.length);
|
||||
}, delay);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [isDeletingWelcome, typedWelcome, welcomeVariantIndex, welcomeVariants]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-md flex-col justify-center py-4">
|
||||
{shouldShowBudgetCompletedMessage && (
|
||||
<UsageLimitBanner
|
||||
tone="danger"
|
||||
icon="⚠️"
|
||||
title={t('home.usageExhaustedTitle')}
|
||||
message={t('home.usageExhaustedBody')}
|
||||
ctaLabel={t('home.usageExhaustedCta')}
|
||||
secondaryCtaLabel={
|
||||
openRouterStatus === 'saving' ? t('openrouterFree.saving') : t('openrouterFree.cta')
|
||||
}
|
||||
onSecondaryCtaClick={() => {
|
||||
if (openRouterStatus !== 'saving') {
|
||||
void handleUseOpenRouterFree();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{openRouterStatus === 'error' && (
|
||||
<div className="mb-3 rounded-lg border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-900/20 dark:text-coral-200">
|
||||
{t('openrouterFree.error')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPromoBanner && <PromotionalCreditsBanner promoCredits={promoCredits} />}
|
||||
|
||||
{/* Main card — sizes to its content. The full height lives on the
|
||||
container (this column is h-full and centers the card), so the
|
||||
composer stays pinned at the bottom of the surface. ~80% tint over
|
||||
the app background. */}
|
||||
<div
|
||||
data-walkthrough="home-card"
|
||||
className="animate-fade-up rounded-2xl border border-stone-200/80 bg-white/80 p-6 shadow-soft backdrop-blur-sm dark:border-neutral-800/80 dark:bg-neutral-900/80">
|
||||
{/* Animated greeting */}
|
||||
<h1 className="min-h-[3.5rem] text-2xl text-center font-bold text-stone-900 dark:text-neutral-100">
|
||||
{typedWelcome}
|
||||
<span aria-hidden="true" className="ml-0.5 inline-block animate-pulse text-primary-500">
|
||||
|
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Description — copy mirrors the active blocking state (incl. the
|
||||
"device connected" get-started line in the normal case). */}
|
||||
<p className="text-center text-sm leading-relaxed text-stone-500 dark:text-neutral-400">
|
||||
{statusCopy}
|
||||
</p>
|
||||
|
||||
{/* Recovery: only when the local core is the broken link. */}
|
||||
{blocking === 'core-unreachable' && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRestartCore}
|
||||
disabled={isRestartingCore}
|
||||
className="w-full rounded-xl bg-amber-500 py-3 font-medium text-white transition-colors duration-200 hover:bg-amber-600 disabled:opacity-50">
|
||||
{isRestartingCore ? t('home.restartingCore') : t('home.restartCore')}
|
||||
</button>
|
||||
{restartError && (
|
||||
<p className="mt-2 text-center text-xs text-coral-500">{restartError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DiscordBanner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,12 @@ function dot() {
|
||||
return <span className="text-stone-300 dark:text-neutral-700">·</span>;
|
||||
}
|
||||
|
||||
export default function ComposerTokenStats() {
|
||||
interface ComposerTokenStatsProps {
|
||||
/** Resolved model id, shown as the leading stat when present. */
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
export default function ComposerTokenStats({ model }: ComposerTokenStatsProps = {}) {
|
||||
const { t } = useT();
|
||||
const usage = useAppSelector(state => state.chatRuntime.sessionTokenUsage);
|
||||
|
||||
@@ -28,7 +33,9 @@ export default function ComposerTokenStats() {
|
||||
const lastIn = usage.lastTurnInputTokens || 0;
|
||||
const lastOut = usage.lastTurnOutputTokens || 0;
|
||||
|
||||
if (turns === 0) return null;
|
||||
// Still render when only the model is known (no turns yet) so the resolved
|
||||
// model stays visible in the composer footer.
|
||||
if (turns === 0 && !model) return null;
|
||||
|
||||
const showIn = ok(inTok);
|
||||
const showOut = ok(outTok);
|
||||
@@ -40,6 +47,13 @@ export default function ComposerTokenStats() {
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
|
||||
if (model) {
|
||||
parts.push(
|
||||
<span key="model" className="truncate" title={model}>
|
||||
{model}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (showIn) {
|
||||
parts.push(
|
||||
<span key="in" title={t('token.inputTokens')}>
|
||||
@@ -72,7 +86,7 @@ export default function ComposerTokenStats() {
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 mt-1.5 text-[10px] font-mono text-stone-400 dark:text-neutral-500 select-none">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2.5 text-[10px] font-mono text-stone-400 dark:text-neutral-500 select-none">
|
||||
{parts.map((part, i) => (
|
||||
<span key={i} className="contents">
|
||||
{i > 0 && dot()}
|
||||
|
||||
@@ -79,28 +79,35 @@ export function UsageLimitBanner({
|
||||
export function PromotionalCreditsBanner({ promoCredits }: { promoCredits: number }) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div className="mb-3 rounded-2xl border border-amber-200 bg-gradient-to-r from-amber-50 via-orange-50 to-rose-50 px-4 py-4 text-left shadow-soft dark:border-amber-500/30 dark:from-amber-900/30 dark:via-amber-900/20 dark:to-amber-900/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-500/20 text-lg">
|
||||
<div className="mb-3 rounded-2xl border border-amber-200 bg-gradient-to-r from-amber-50 via-orange-50 to-rose-50 px-4 py-2.5 text-left shadow-soft dark:border-amber-500/30 dark:from-amber-900/30 dark:via-amber-900/20 dark:to-amber-900/10">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="mt-px flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-amber-100 text-base dark:bg-amber-500/20">
|
||||
🎉
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-amber-700 dark:text-amber-300">
|
||||
{t('home.banners.promoCreditsTitle').replace('{amount}', formatUsd(promoCredits))}
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-relaxed text-amber-600 dark:text-amber-300/80">
|
||||
{t('home.banners.promoCreditsBody')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void openUrl(BILLING_DASHBOARD_URL);
|
||||
}}
|
||||
className="cursor-pointer border-b border-amber-700 border-dashed font-bold text-amber-700 hover:text-amber-800 dark:border-amber-300 dark:text-amber-300 dark:hover:text-amber-200">
|
||||
{t('home.banners.getSubscription')}
|
||||
</button>{' '}
|
||||
{t('home.banners.promoCreditsUsage')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 text-sm leading-relaxed text-amber-600 dark:text-amber-300/80">
|
||||
{(() => {
|
||||
// Single {amount} template; split so the amount renders bold inline.
|
||||
const [before, after] = t('home.banners.promoCreditsBody').split('{amount}');
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<span className="font-semibold text-amber-700 dark:text-amber-300">
|
||||
{formatUsd(promoCredits)}
|
||||
</span>
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
})()}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void openUrl(BILLING_DASHBOARD_URL);
|
||||
}}
|
||||
className="cursor-pointer border-b border-dashed border-amber-700 font-bold text-amber-700 hover:text-amber-800 dark:border-amber-300 dark:text-amber-300 dark:hover:text-amber-200">
|
||||
{t('home.banners.getSubscription')}
|
||||
</button>{' '}
|
||||
{t('home.banners.promoCreditsUsage')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -156,18 +163,18 @@ export function DiscordBanner() {
|
||||
onClick={() => {
|
||||
void openUrl(DISCORD_INVITE_URL);
|
||||
}}
|
||||
className="mb-3 text-left mt-3 block w-full rounded-2xl border border-[#CDD2FF] bg-gradient-to-r from-[#F6F7FF] via-[#F1F3FF] to-[#ECEFFF] px-4 py-4 text-[#414AAE] shadow-soft transition-transform transition-colors hover:-translate-y-0.5 hover:border-[#BCC3FF] hover:from-[#EEF0FF] hover:to-[#E5E9FF] dark:border-[#5865F2]/30 dark:from-[#5865F2]/10 dark:via-[#5865F2]/15 dark:to-[#5865F2]/10 dark:text-[#A5B0FF] dark:hover:border-[#5865F2]/50 dark:hover:from-[#5865F2]/15 dark:hover:to-[#5865F2]/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#5865F2]/12 text-[#5865F2]">
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
className="mb-3 text-left mt-3 block w-full rounded-2xl border border-[#CDD2FF] bg-gradient-to-r from-[#F6F7FF] via-[#F1F3FF] to-[#ECEFFF] px-4 py-2.5 text-[#414AAE] shadow-soft transition-transform transition-colors hover:-translate-y-0.5 hover:border-[#BCC3FF] hover:from-[#EEF0FF] hover:to-[#E5E9FF] dark:border-[#5865F2]/30 dark:from-[#5865F2]/10 dark:via-[#5865F2]/15 dark:to-[#5865F2]/10 dark:text-[#A5B0FF] dark:hover:border-[#5865F2]/50 dark:hover:from-[#5865F2]/15 dark:hover:to-[#5865F2]/20">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[#5865F2]/12 text-[#5865F2]">
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20.317 4.37A19.79 19.79 0 0 0 15.885 3c-.191.328-.403.775-.552 1.124a18.27 18.27 0 0 0-5.29 0A11.56 11.56 0 0 0 9.49 3a19.74 19.74 0 0 0-4.433 1.37C2.253 8.51 1.492 12.55 1.872 16.533a19.9 19.9 0 0 0 5.239 2.673c.423-.58.8-1.196 1.123-1.845a12.84 12.84 0 0 1-1.767-.85c.148-.106.292-.217.43-.332c3.408 1.6 7.104 1.6 10.472 0c.14.115.283.226.43.332c-.565.338-1.157.623-1.771.851c.322.648.698 1.264 1.123 1.844a19.84 19.84 0 0 0 5.241-2.673c.446-4.617-.761-8.621-3.787-12.164ZM9.46 14.088c-1.02 0-1.855-.936-1.855-2.084c0-1.148.82-2.084 1.855-2.084c1.044 0 1.87.944 1.855 2.084c0 1.148-.82 2.084-1.855 2.084Zm5.08 0c-1.02 0-1.855-.936-1.855-2.084c0-1.148.82-2.084 1.855-2.084c1.044 0 1.87.944 1.855 2.084c0 1.148-.812 2.084-1.855 2.084Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold">{t('home.banners.discordTitle')}</div>
|
||||
<div className="mt-0.5 text-sm text-[#5E66BC] dark:text-[#8B95DD]">
|
||||
<div className="min-w-0 flex-1 text-sm">
|
||||
<span className="font-semibold">{t('home.banners.discordTitle')}</span>{' '}
|
||||
<span className="text-[#5E66BC] dark:text-[#8B95DD]">
|
||||
{t('home.banners.discordSubtitle')}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('HomeBanners', () => {
|
||||
it('opens the billing dashboard through openUrl from the promotional credits banner', () => {
|
||||
render(<PromotionalCreditsBanner promoCredits={12} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Get a subscription' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /get a subscription/i }));
|
||||
|
||||
expect(openUrl).toHaveBeenCalledWith('https://tinyhumans.ai/dashboard');
|
||||
});
|
||||
|
||||
@@ -156,11 +156,11 @@ export function createSimulation(
|
||||
'charge',
|
||||
forceManyBody<SimNode>()
|
||||
.strength(n => {
|
||||
if (n.kind === 'root') return -2000;
|
||||
if (n.kind === 'source') return -800;
|
||||
return -400;
|
||||
if (n.kind === 'root') return -650;
|
||||
if (n.kind === 'source') return -280;
|
||||
return -140;
|
||||
})
|
||||
.distanceMax(600)
|
||||
.distanceMax(300)
|
||||
)
|
||||
.force(
|
||||
'link',
|
||||
@@ -169,13 +169,13 @@ export function createSimulation(
|
||||
.distance(link => {
|
||||
const src = link.source as SimNode;
|
||||
const tgt = link.target as SimNode;
|
||||
if (src.kind === 'root' || tgt.kind === 'root') return 250;
|
||||
if (src.kind === 'source' || tgt.kind === 'source') return 100;
|
||||
return 58;
|
||||
if (src.kind === 'root' || tgt.kind === 'root') return 90;
|
||||
if (src.kind === 'source' || tgt.kind === 'source') return 40;
|
||||
return 22;
|
||||
})
|
||||
.strength(0.35)
|
||||
.strength(0.7)
|
||||
)
|
||||
.force('center', forceCenter(0, 0).strength(0.04))
|
||||
.force('center', forceCenter(0, 0).strength(0.12))
|
||||
.force(
|
||||
'collide',
|
||||
forceCollide<SimNode>().radius(n => {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { APP_VERSION } from '../../../utils/config';
|
||||
import ConnectionIndicator from '../../ConnectionIndicator';
|
||||
import SidebarHeader from './SidebarHeader';
|
||||
import SidebarNav from './SidebarNav';
|
||||
import { SidebarSlotOutlet } from './SidebarSlot';
|
||||
|
||||
/**
|
||||
* The root-shell sidebar, split top-to-bottom into:
|
||||
*
|
||||
* ┌──────────────┐
|
||||
* │ SidebarHeader │ utility row (collapse / settings / language)
|
||||
* ├──────────────┤
|
||||
* │ SidebarNav │ static primary navigation
|
||||
* ├──────────────┤
|
||||
* │ SidebarSlot │ dynamic, per-route content (scrolls)
|
||||
* │ (Outlet) │
|
||||
* ├──────────────┤
|
||||
* │ beta footer │ app-wide build/version line
|
||||
* └──────────────┘
|
||||
*
|
||||
* Pages project content into the slot region with {@link SidebarContent}.
|
||||
* Background matches the previous in-page sidebar pane (white / neutral-900).
|
||||
*/
|
||||
export default function AppSidebar() {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-neutral-900">
|
||||
<div className="flex-shrink-0 border-b border-stone-200/70 dark:border-neutral-800/70">
|
||||
<SidebarHeader />
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<SidebarNav />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto border-t border-stone-200/70 dark:border-neutral-800/70">
|
||||
{/* Flex column so routes that project more than one region (e.g. Chat's
|
||||
app rail above its thread list) can order them via Tailwind `order-*`. */}
|
||||
<SidebarSlotOutlet className="flex h-full flex-col" />
|
||||
</div>
|
||||
{/* App-wide footer: connectivity status + build/version, pinned to the
|
||||
bottom of the sidebar. */}
|
||||
<div className="flex flex-shrink-0 items-center justify-center gap-2 border-t border-stone-200 px-2 py-1.5 dark:border-neutral-800">
|
||||
<ConnectionIndicator />
|
||||
·
|
||||
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
|
||||
{t('settings.betaBuild').replace('{version}', APP_VERSION)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import {
|
||||
ensurePanelLayout,
|
||||
selectPanelLayout,
|
||||
setSidebarVisible,
|
||||
setSidebarWidth,
|
||||
toggleSidebar,
|
||||
} from '../../../store/layoutSlice';
|
||||
|
||||
// `app-shell` (not the older `root-shell`) so the persisted geometry seeds
|
||||
// fresh with the sidebar visible by default.
|
||||
const LAYOUT_ID = 'app-shell';
|
||||
const DEFAULT_WIDTH = 224;
|
||||
const MIN_WIDTH = 188;
|
||||
const MAX_WIDTH = 420;
|
||||
const KEYBOARD_STEP = 16;
|
||||
const LAYOUT_DEFAULTS = { sidebarVisible: true, sidebarWidth: DEFAULT_WIDTH };
|
||||
|
||||
function clamp(width: number): number {
|
||||
return Math.min(Math.max(width, MIN_WIDTH), MAX_WIDTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the root shell sidebar's visibility and get helpers to drive it
|
||||
* from chrome that lives elsewhere (e.g. the in-sidebar header's collapse
|
||||
* button, or a reshow button in the content area).
|
||||
*/
|
||||
export function useRootSidebar() {
|
||||
const dispatch = useAppDispatch();
|
||||
const layout = useAppSelector(selectPanelLayout(LAYOUT_ID, LAYOUT_DEFAULTS));
|
||||
return {
|
||||
visible: layout.sidebarVisible,
|
||||
toggle: useCallback(() => dispatch(toggleSidebar({ id: LAYOUT_ID })), [dispatch]),
|
||||
show: useCallback(
|
||||
() => dispatch(setSidebarVisible({ id: LAYOUT_ID, visible: true })),
|
||||
[dispatch]
|
||||
),
|
||||
hide: useCallback(
|
||||
() => dispatch(setSidebarVisible({ id: LAYOUT_ID, visible: false })),
|
||||
[dispatch]
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export interface RootShellLayoutProps {
|
||||
/** Always-visible left pane (the app sidebar). */
|
||||
sidebar: ReactNode;
|
||||
/** Dynamic main content (the routed page area). */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-bleed, viewport-filling two-pane shell for the app root: a resizable
|
||||
* sidebar on the left and the main content on the right, separated by a flush
|
||||
* hairline seam. Unlike the in-page {@link TwoPanelLayout}, this fills its
|
||||
* container edge-to-edge (no card, no rounded corners) because it *is* the
|
||||
* window chrome. The dragged width persists per user via the `layout` slice
|
||||
* (id `root-shell`); the sidebar is always shown.
|
||||
*/
|
||||
export default function RootShellLayout({ sidebar, children }: RootShellLayoutProps) {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
const layout = useAppSelector(selectPanelLayout(LAYOUT_ID, LAYOUT_DEFAULTS));
|
||||
const persistedWidth = clamp(layout.sidebarWidth);
|
||||
const isOpen = layout.sidebarVisible;
|
||||
|
||||
// Seed persisted geometry once so the selector returns a stable stored
|
||||
// reference on subsequent renders (avoids the new-object memoization warning).
|
||||
useEffect(() => {
|
||||
dispatch(ensurePanelLayout({ id: LAYOUT_ID, defaults: LAYOUT_DEFAULTS }));
|
||||
}, [dispatch]);
|
||||
|
||||
const [dragWidth, setDragWidth] = useState<number | null>(null);
|
||||
const dragWidthRef = useRef<number | null>(null);
|
||||
const dragCleanupRef = useRef<(() => void) | null>(null);
|
||||
const width = dragWidth ?? persistedWidth;
|
||||
|
||||
const commitWidth = useCallback(
|
||||
(next: number) => dispatch(setSidebarWidth({ id: LAYOUT_ID, width: clamp(Math.round(next)) })),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = width;
|
||||
dragWidthRef.current = startWidth;
|
||||
setDragWidth(startWidth);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
function handleMove(ev: PointerEvent) {
|
||||
const next = clamp(startWidth + (ev.clientX - startX));
|
||||
dragWidthRef.current = next;
|
||||
setDragWidth(next);
|
||||
}
|
||||
function detach() {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', stop);
|
||||
window.removeEventListener('pointercancel', stop);
|
||||
window.removeEventListener('blur', stop);
|
||||
document.body.style.removeProperty('cursor');
|
||||
document.body.style.removeProperty('user-select');
|
||||
dragCleanupRef.current = null;
|
||||
}
|
||||
function stop() {
|
||||
detach();
|
||||
const finalWidth = dragWidthRef.current;
|
||||
dragWidthRef.current = null;
|
||||
setDragWidth(null);
|
||||
if (finalWidth != null) commitWidth(finalWidth);
|
||||
}
|
||||
|
||||
dragCleanupRef.current = detach;
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', stop);
|
||||
window.addEventListener('pointercancel', stop);
|
||||
window.addEventListener('blur', stop);
|
||||
},
|
||||
[width, commitWidth]
|
||||
);
|
||||
|
||||
// Detach global listeners if we unmount mid-drag.
|
||||
useLayoutEffect(() => () => dragCleanupRef.current?.(), []);
|
||||
|
||||
const onDividerKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
commitWidth(persistedWidth - KEYBOARD_STEP);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
commitWidth(persistedWidth + KEYBOARD_STEP);
|
||||
}
|
||||
},
|
||||
[commitWidth, persistedWidth]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full min-h-0 overflow-hidden">
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="flex-shrink-0 min-w-0 overflow-hidden"
|
||||
style={{ width }}
|
||||
data-testid="root-shell-sidebar">
|
||||
{sidebar}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t('layout.resizeSidebar')}
|
||||
aria-valuenow={Math.round(width)}
|
||||
aria-valuemin={MIN_WIDTH}
|
||||
aria-valuemax={MAX_WIDTH}
|
||||
tabIndex={0}
|
||||
data-testid="root-shell-divider"
|
||||
data-analytics-id="root-shell-resize-divider"
|
||||
onPointerDown={onPointerDown}
|
||||
onKeyDown={onDividerKeyDown}
|
||||
title={t('layout.resizeSidebar')}
|
||||
className="group relative w-px flex-shrink-0 cursor-col-resize select-none self-stretch bg-stone-200 dark:bg-neutral-800 focus:outline-none">
|
||||
<span className="absolute inset-y-0 -left-1 -right-1 z-10" />
|
||||
<span className="absolute inset-0 transition-colors group-hover:bg-primary-400 group-focus:bg-primary-500" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Reshow affordance — only when the sidebar is collapsed. A thin rail
|
||||
that occupies layout space (NOT an overlay) so the content — and the
|
||||
native CEF webview glued to the content's bounds, which composites
|
||||
above the HTML layer — starts to its right and never covers it. */}
|
||||
{!isOpen && (
|
||||
<div className="flex w-9 flex-none flex-col items-center border-r border-stone-200 bg-white pt-2 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch(setSidebarVisible({ id: LAYOUT_ID, visible: true }))}
|
||||
data-testid="root-shell-reopen"
|
||||
data-analytics-id="root-shell-reopen-sidebar"
|
||||
aria-label={t('layout.showSidebar')}
|
||||
title={t('layout.showSidebar')}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-stone-500 transition-colors hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 overflow-hidden" data-testid="root-shell-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import type { Locale } from '../../../lib/i18n/types';
|
||||
import { setActiveAccount } from '../../../store/accountsSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import { setLocale } from '../../../store/localeSlice';
|
||||
import { createNewThread, loadThreadMessages, setSelectedThread } from '../../../store/threadSlice';
|
||||
import { AGENT_ACCOUNT_ID } from '../../../utils/accountsFullscreen';
|
||||
import { LOCALE_OPTIONS } from '../../LanguageSelect';
|
||||
import { useRootSidebar } from './RootShellLayout';
|
||||
|
||||
const ICON_BTN =
|
||||
'flex h-7 w-7 flex-none items-center justify-center rounded-md text-stone-500 transition-colors hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200';
|
||||
|
||||
/**
|
||||
* Thin utility header at the top of the root sidebar: collapse the sidebar,
|
||||
* jump to Settings, and switch language. The language control is a globe icon
|
||||
* with a transparent native <select> overlaid on top, so clicking the icon
|
||||
* opens the OS locale picker (reusing the shared LOCALE_OPTIONS + setLocale).
|
||||
*/
|
||||
export default function SidebarHeader() {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const dispatch = useAppDispatch();
|
||||
const { hide } = useRootSidebar();
|
||||
const locale = useAppSelector(state => state.locale.current);
|
||||
const threads = useAppSelector(state => state.thread.threads);
|
||||
|
||||
// Home → the unified chat on a blank thread. When we're NOT already on chat,
|
||||
// just navigate and let the mounting Conversations page own blank-thread
|
||||
// landing (avoids a duplicate-create race). When already on chat (no remount),
|
||||
// reset to a blank thread here: reuse an existing empty one, else create.
|
||||
const handleHome = () => {
|
||||
// Switch back to the agent account first — otherwise a selected connected
|
||||
// app (WhatsApp/Slack/…) keeps Accounts rendering its webview instead of the
|
||||
// blank agent thread.
|
||||
dispatch(setActiveAccount(AGENT_ACCOUNT_ID));
|
||||
const onChat = location.pathname === '/chat' || location.pathname.startsWith('/chat/');
|
||||
if (!onChat) {
|
||||
navigate('/chat');
|
||||
return;
|
||||
}
|
||||
const empty = threads.find(thr => (thr.messageCount ?? 0) === 0);
|
||||
if (empty) {
|
||||
dispatch(setSelectedThread(empty.id));
|
||||
void dispatch(loadThreadMessages(empty.id));
|
||||
return;
|
||||
}
|
||||
void dispatch(createNewThread())
|
||||
.unwrap()
|
||||
.then(thr => {
|
||||
dispatch(setSelectedThread(thr.id));
|
||||
void dispatch(loadThreadMessages(thr.id));
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-1 px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHome}
|
||||
className={ICON_BTN}
|
||||
data-analytics-id="sidebar-header-home"
|
||||
aria-label={t('nav.home')}
|
||||
title={t('nav.home')}>
|
||||
<svg className="h-4 w-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>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Language — globe icon with a transparent select overlay. `overflow-hidden`
|
||||
clips the native <select> to the icon box: a select won't shrink below
|
||||
its longest option's width, so without clipping it spills to the right
|
||||
and steals clicks from the Settings / collapse buttons. */}
|
||||
<label className={`relative overflow-hidden ${ICON_BTN}`} title={t('settings.language')}>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0zM3.6 9h16.8M3.6 15h16.8M12 3a15 15 0 010 18 15 15 0 010-18z"
|
||||
/>
|
||||
</svg>
|
||||
<select
|
||||
value={locale}
|
||||
onChange={e => dispatch(setLocale(e.target.value as Locale))}
|
||||
aria-label={t('settings.language')}
|
||||
data-analytics-id="sidebar-header-language"
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0">
|
||||
{LOCALE_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.flag} {opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/settings')}
|
||||
className={ICON_BTN}
|
||||
data-analytics-id="sidebar-header-settings"
|
||||
aria-label={t('nav.settings')}
|
||||
title={t('nav.settings')}>
|
||||
<svg className="h-4 w-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>
|
||||
</button>
|
||||
|
||||
{/* Collapse the sidebar — sits on the right, next to Settings. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={hide}
|
||||
className={ICON_BTN}
|
||||
data-analytics-id="sidebar-header-collapse"
|
||||
aria-label={t('chat.hideSidebar')}
|
||||
title={t('chat.hideSidebar')}>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M15 19l-7-7 7-7M20 5v14"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { NAV_TABS, type NavTab } from '../../../config/navConfig';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { selectCompanionSessionActive } from '../../../store/companionSlice';
|
||||
import { useAppSelector } from '../../../store/hooks';
|
||||
import { selectUnreadCount } from '../../../store/notificationSlice';
|
||||
import { NavIcon } from './navIcons';
|
||||
|
||||
/**
|
||||
* Active-route matching for a nav entry. Mirrors the rules the former
|
||||
* `BottomTabBar` used so deep links keep their tab highlighted:
|
||||
* - `/chat` → any `/chat...` route
|
||||
* - `/settings` → the settings index and every `/settings/*` panel
|
||||
* - `/home` → exact match (so `/` redirects don't light it up)
|
||||
*/
|
||||
function matchActive(path: string, pathname: string): boolean {
|
||||
if (path === '/chat') return pathname.startsWith('/chat');
|
||||
if (path === '/settings') return pathname === '/settings' || pathname.startsWith('/settings/');
|
||||
if (path === '/home') return pathname === '/home';
|
||||
return pathname === path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static, always-visible navigation rail — the top region of the root-shell
|
||||
* sidebar. Renders one icon + label row per {@link NAV_TABS} entry. This is the
|
||||
* relocated home of the old floating bottom tab bar's primary destinations.
|
||||
*/
|
||||
export default function SidebarNav() {
|
||||
const { t } = useT();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const unreadCount = useAppSelector(state => selectUnreadCount(state.notifications.items));
|
||||
const companionActive = useAppSelector(selectCompanionSessionActive);
|
||||
|
||||
const tabs = useMemo(() => NAV_TABS.map(tab => ({ ...tab, label: t(tab.labelKey) })), [t]);
|
||||
const activeTab = tabs.find(tab => matchActive(tab.path, location.pathname));
|
||||
|
||||
const handleClick = (tab: NavTab, active: boolean) => {
|
||||
if (!active) {
|
||||
trackEvent('tab_bar_change', {
|
||||
from_tab: activeTab?.id ?? 'unknown',
|
||||
to_tab: tab.id,
|
||||
from_path: location.pathname,
|
||||
to_path: tab.path,
|
||||
});
|
||||
}
|
||||
navigate(tab.path);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col gap-px p-1.5" aria-label={t('nav.home')}>
|
||||
{tabs.map(tab => {
|
||||
const active = matchActive(tab.path, location.pathname);
|
||||
const showBadge = tab.id === 'notifications' && unreadCount > 0;
|
||||
const showCompanionDot = tab.id === 'settings' && companionActive;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
data-walkthrough={tab.walkthroughAttr}
|
||||
onClick={() => handleClick(tab, active)}
|
||||
title={tab.label}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`group flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[13px] transition-colors cursor-pointer ${
|
||||
active
|
||||
? 'bg-white dark:bg-neutral-800 text-stone-900 dark:text-neutral-100 font-semibold shadow-sm'
|
||||
: 'text-stone-500 dark:text-neutral-400 hover:bg-stone-200/70 dark:hover:bg-neutral-800/60 hover:text-stone-700 dark:hover:text-neutral-200'
|
||||
}`}>
|
||||
<span className="relative inline-flex flex-shrink-0">
|
||||
<NavIcon id={tab.id} className="w-4 h-4" />
|
||||
{showBadge && (
|
||||
<span className="absolute -top-1 -right-1 min-w-[13px] h-[13px] px-1 rounded-full bg-coral-500 text-[9px] font-bold text-white flex items-center justify-center leading-none">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
{showCompanionDot && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-blue-500 animate-pulse" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import debugFactory from 'debug';
|
||||
import { createContext, type ReactNode, useContext, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { IS_DEV } from '../../../utils/config';
|
||||
|
||||
const debug = debugFactory('layout:sidebar-slot');
|
||||
|
||||
/**
|
||||
* Portal-based plumbing for the root shell's *dynamic* sidebar region.
|
||||
*
|
||||
* The shell renders {@link SidebarSlotOutlet} once (the middle band of
|
||||
* `AppSidebar`, between the static nav and the account menu). Any routed page
|
||||
* can then render {@link SidebarContent} to project its own sidebar UI into
|
||||
* that region. Because it's a React portal, the projected content stays inside
|
||||
* the page's own component tree — it keeps the page's context, hooks and local
|
||||
* state — while rendering into the sidebar's DOM node.
|
||||
*
|
||||
* Routes that don't render `SidebarContent` simply leave the region empty.
|
||||
*/
|
||||
interface SidebarSlotContextValue {
|
||||
/** The live DOM node of the dynamic region, or null before first mount. */
|
||||
target: HTMLElement | null;
|
||||
/** Stable setter (a `useState` dispatch) registering the region's node. */
|
||||
setTarget: (el: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
const SidebarSlotContext = createContext<SidebarSlotContextValue | null>(null);
|
||||
|
||||
export function SidebarSlotProvider({ children }: { children: ReactNode }) {
|
||||
const [target, setTarget] = useState<HTMLElement | null>(null);
|
||||
return (
|
||||
<SidebarSlotContext.Provider value={{ target, setTarget }}>
|
||||
{children}
|
||||
</SidebarSlotContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks where dynamic sidebar content lands. Rendered once by `AppSidebar`.
|
||||
* The ref handler is the stable `useState` dispatch, so React attaches/detaches
|
||||
* it exactly once (no per-render portal thrash).
|
||||
*/
|
||||
export function SidebarSlotOutlet({ className }: { className?: string }) {
|
||||
const ctx = useContext(SidebarSlotContext);
|
||||
if (!ctx) {
|
||||
if (IS_DEV) {
|
||||
debug('SidebarSlotOutlet rendered outside SidebarSlotProvider');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return <div ref={ctx.setTarget} className={className} data-testid="sidebar-slot-outlet" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered by a routed page to inject content into the shell's dynamic sidebar
|
||||
* region. Renders nothing until the outlet has mounted (first paint), then
|
||||
* portals its children there. Safe to render when no provider is present
|
||||
* (returns null) so pages stay usable in isolated tests.
|
||||
*/
|
||||
export function SidebarContent({ children }: { children: ReactNode }) {
|
||||
const ctx = useContext(SidebarSlotContext);
|
||||
if (!ctx?.target) return null;
|
||||
return createPortal(children, ctx.target);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Line-art icons for the static sidebar navigation, keyed by `NavTab.id`.
|
||||
*
|
||||
* Extracted from the former `BottomTabBar` so the root-shell `SidebarNav` owns
|
||||
* the icon set. Pure presentational SVGs — no state, no i18n.
|
||||
*/
|
||||
|
||||
interface NavIconProps {
|
||||
id: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) {
|
||||
switch (id) {
|
||||
case 'home':
|
||||
return (
|
||||
<svg className={className} 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={className} 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={className} 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={className} 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':
|
||||
return (
|
||||
<svg className={className} 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={className} 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>
|
||||
);
|
||||
case 'brain':
|
||||
return (
|
||||
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M9.5 2A2.5 2.5 0 0112 4.5v15a2.5 2.5 0 01-4.96.44 2.5 2.5 0 01-2.96-3.08 3 3 0 01-.34-5.58 2.5 2.5 0 011.32-4.24 2.5 2.5 0 011.98-3A2.5 2.5 0 019.5 2z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M14.5 2A2.5 2.5 0 0012 4.5v15a2.5 2.5 0 004.96.44 2.5 2.5 0 002.96-3.08 3 3 0 00.34-5.58 2.5 2.5 0 00-1.32-4.24 2.5 2.5 0 00-1.98-3A2.5 2.5 0 0014.5 2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import debug from 'debug';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import TwoPanelLayout from '../../layout/TwoPanelLayout';
|
||||
import { SidebarContent } from '../../layout/shell/SidebarSlot';
|
||||
import { SettingsLayoutProvider } from './SettingsLayoutContext';
|
||||
import SettingsSidebar from './SettingsSidebar';
|
||||
import SettingsSubNav from './SettingsSubNav';
|
||||
@@ -9,51 +9,33 @@ import SettingsSubNav from './SettingsSubNav';
|
||||
const log = debug('settings:layout');
|
||||
|
||||
/**
|
||||
* Two-pane settings shell, built on the reusable {@link TwoPanelLayout}.
|
||||
*
|
||||
* The grouped navigation sidebar is always shown and the layout spans the full
|
||||
* width of the page; the sidebar is resizable (drag the divider) and its width
|
||||
* persists per user via the `layout` slice (id `settings`). Each pane scrolls
|
||||
* independently, so the nav and the routed panel never fight over one
|
||||
* scrollbar.
|
||||
* Settings shell. The grouped navigation now lives in the root app sidebar's
|
||||
* dynamic region (projected via {@link SidebarContent}); this component only
|
||||
* renders the routed panel — the sub-nav chips pinned at top and the routed
|
||||
* page owning the single vertical scroll below.
|
||||
*/
|
||||
const SettingsLayout = () => {
|
||||
log('render');
|
||||
|
||||
return (
|
||||
<SettingsLayoutProvider value={{ inTwoPaneShell: true }}>
|
||||
<TwoPanelLayout
|
||||
id="settings"
|
||||
// Max-width is applied once to the whole panel (sidebar + content
|
||||
// together) and centered, rather than capping each settings panel.
|
||||
// `seamless` joins both panes into one bordered card with a flush,
|
||||
// draggable hairline seam — no gutter between the nav and the panel.
|
||||
className="mx-auto h-full w-full max-w-6xl p-4 pt-6"
|
||||
defaultSidebarVisible
|
||||
defaultSidebarWidth={288}
|
||||
minSidebarWidth={220}
|
||||
maxSidebarWidth={420}
|
||||
seamless
|
||||
sidebar={
|
||||
// overflow-hidden so the scroll lives on the sidebar's own content
|
||||
// area (below the fixed search header), not this wrapper.
|
||||
<div className="h-full overflow-hidden">
|
||||
<SettingsSidebar />
|
||||
</div>
|
||||
}>
|
||||
{/* Bounded flex column: the sub-nav chips stay pinned at the top while
|
||||
the routed panel owns the only vertical scroll (its WrappedSettingsPage
|
||||
/ PanelScaffold). No scroll here — that's what caused the page to
|
||||
scroll twice. */}
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex-shrink-0">
|
||||
<SettingsSubNav />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
<SidebarContent>
|
||||
<div className="h-full overflow-hidden">
|
||||
<SettingsSidebar />
|
||||
</div>
|
||||
</TwoPanelLayout>
|
||||
</SidebarContent>
|
||||
{/* Bounded flex column: the sub-nav chips stay pinned at the top while
|
||||
the routed panel owns the only vertical scroll (its WrappedSettingsPage
|
||||
/ PanelScaffold). The panel is wrapped in a card so settings pages get
|
||||
a surface/background instead of sitting flush on the shell. */}
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col gap-3 p-4">
|
||||
<div className="flex-shrink-0">
|
||||
<SettingsSubNav />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-soft dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLayoutProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { APP_VERSION } from '../../../utils/config';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
import SettingsSearchBar from '../search/SettingsSearchBar';
|
||||
import { useSettingsSearch } from '../search/useSettingsSearch';
|
||||
@@ -138,11 +137,6 @@ const SettingsSidebar = () => {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky, centered version footer. */}
|
||||
<div className="shrink-0 border-t border-stone-200 py-1 text-center text-[10px] text-stone-400 dark:border-neutral-800 dark:text-neutral-500">
|
||||
{t('settings.betaBuild').replace('{version}', APP_VERSION)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,54 +3,41 @@ import { describe, expect, it } from 'vitest';
|
||||
import { AVATAR_MENU_ITEMS, NAV_TABS } from '../navConfig';
|
||||
|
||||
describe('NAV_TABS', () => {
|
||||
it('has exactly 6 entries', () => {
|
||||
expect(NAV_TABS).toHaveLength(6);
|
||||
it('has exactly 4 entries', () => {
|
||||
expect(NAV_TABS).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('has the correct ids in order', () => {
|
||||
expect(NAV_TABS.map(t => t.id)).toEqual([
|
||||
'home',
|
||||
'chat',
|
||||
'human',
|
||||
'brain',
|
||||
'connections',
|
||||
'settings',
|
||||
]);
|
||||
expect(NAV_TABS.map(t => t.id)).toEqual(['chat', 'human', 'brain', 'connections']);
|
||||
});
|
||||
|
||||
it('has the correct paths', () => {
|
||||
expect(NAV_TABS.map(t => t.path)).toEqual([
|
||||
'/home',
|
||||
'/chat',
|
||||
'/human',
|
||||
'/brain',
|
||||
'/connections',
|
||||
'/settings',
|
||||
]);
|
||||
expect(NAV_TABS.map(t => t.path)).toEqual(['/chat', '/human', '/brain', '/connections']);
|
||||
});
|
||||
|
||||
it('has the correct labelKeys', () => {
|
||||
expect(NAV_TABS.map(t => t.labelKey)).toEqual([
|
||||
'nav.home',
|
||||
'nav.chat',
|
||||
'nav.human',
|
||||
'nav.brain',
|
||||
'nav.connections',
|
||||
'nav.settings',
|
||||
]);
|
||||
});
|
||||
|
||||
it('has the correct walkthroughAttrs', () => {
|
||||
expect(NAV_TABS.map(t => t.walkthroughAttr)).toEqual([
|
||||
'tab-home',
|
||||
'tab-chat',
|
||||
'tab-human',
|
||||
'tab-brain',
|
||||
'tab-connections',
|
||||
'tab-settings',
|
||||
]);
|
||||
});
|
||||
|
||||
it('no longer contains home or settings tabs (moved to the sidebar header)', () => {
|
||||
expect(NAV_TABS.find(t => t.id === 'home')).toBeUndefined();
|
||||
expect(NAV_TABS.find(t => t.id === 'settings')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not contain an activity tab', () => {
|
||||
expect(NAV_TABS.find(t => t.id === 'activity')).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -20,17 +20,19 @@ export interface NavTab {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered list of bottom-bar tabs. Six entries:
|
||||
* home → chat → human → brain → connections → settings
|
||||
* Ordered list of sidebar nav entries. Four entries:
|
||||
* chat → human → brain → connections
|
||||
*
|
||||
* Chat is a regular pill tab (second after home). The Human tab is a
|
||||
* first-class destination again (it was briefly merged into Assistant in
|
||||
* IA Phase 6, then restored): `/human` renders the Human page on desktop.
|
||||
* Ids/paths/walkthroughAttrs travel with each tab, so analytics and the
|
||||
* walkthrough tour stay attached to the right feature regardless of position.
|
||||
* Settings has no primary tab — it's reached via the gear icon in the sidebar
|
||||
* header. Chat is the default landing and the merged Home surface: its empty
|
||||
* "new window" state shows the former Home greeting + banners (Home was
|
||||
* folded into chat, so there is no separate Home entry). The Human tab is a
|
||||
* first-class destination again (briefly merged into Assistant in IA Phase 6,
|
||||
* then restored): `/human` renders the Human page on desktop. Ids/paths/
|
||||
* walkthroughAttrs travel with each tab so analytics and the walkthrough tour
|
||||
* stay attached to the right feature regardless of position.
|
||||
*/
|
||||
export const NAV_TABS: NavTab[] = [
|
||||
{ id: 'home', labelKey: 'nav.home', path: '/home', walkthroughAttr: 'tab-home' },
|
||||
{ id: 'chat', labelKey: 'nav.chat', path: '/chat', walkthroughAttr: 'tab-chat' },
|
||||
{ id: 'human', labelKey: 'nav.human', path: '/human', walkthroughAttr: 'tab-human' },
|
||||
{ id: 'brain', labelKey: 'nav.brain', path: '/brain', walkthroughAttr: 'tab-brain' },
|
||||
@@ -40,7 +42,8 @@ export const NAV_TABS: NavTab[] = [
|
||||
path: '/connections',
|
||||
walkthroughAttr: 'tab-connections',
|
||||
},
|
||||
{ id: 'settings', labelKey: 'nav.settings', path: '/settings', walkthroughAttr: 'tab-settings' },
|
||||
// Settings is reached via the gear icon in the sidebar header, so it no
|
||||
// longer has its own primary nav tab.
|
||||
];
|
||||
|
||||
// ── Avatar / account menu ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -49,7 +49,7 @@ const HumanPage = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Mascot stage — fills the area to the left of the reserved sidebar column. */}
|
||||
{/* Mascot stage — fills the area to the left of the reserved chat column. */}
|
||||
<div className="absolute inset-y-0 left-0 right-[436px] flex items-center justify-center">
|
||||
<div className="relative w-[min(80vh,90%)] aspect-square">
|
||||
{customMascotGifUrl ? (
|
||||
@@ -76,10 +76,13 @@ const HumanPage = () => {
|
||||
{t('voice.pushToTalk')}
|
||||
</label>
|
||||
|
||||
{/* Chat sidebar — vertically centered above the BottomTabBar (~80px). */}
|
||||
<div className="absolute right-4 top-0 bottom-20 z-10 flex items-center">
|
||||
<aside className="w-[420px] h-[min(720px,calc(100vh-160px))] rounded-2xl border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-soft flex flex-col overflow-hidden">
|
||||
<Conversations variant="sidebar" composer="mic-cloud" />
|
||||
{/* Chat panel — kept on the right (the Human page is intentionally the
|
||||
one surface that leaves the root sidebar's dynamic region empty). */}
|
||||
<div className="absolute right-4 top-4 bottom-4 z-10 flex items-center">
|
||||
<aside className="w-[420px] h-[min(760px,100%)] rounded-2xl border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-soft flex flex-col overflow-hidden">
|
||||
{/* Right-rail chat, but its thread list is surfaced in the (otherwise
|
||||
empty) root sidebar so the Human page shows the user's threads. */}
|
||||
<Conversations variant="sidebar" composer="mic-cloud" projectThreadList />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -294,8 +294,7 @@ const messages: TranslationMap = {
|
||||
'home.greetingAfternoon': 'مساء الخير',
|
||||
'home.greetingEvening': 'مساء النور',
|
||||
'home.askAssistant': 'اسأل مساعدك أي شيء...',
|
||||
'home.statusOk':
|
||||
'جهازك متصل. احتفظ بالتطبيق مفتوحًا للحفاظ على الاتصال. راسل وكيلك باستخدام الزر أدناه.',
|
||||
'home.statusOk': 'مساعدك جاهز متى كنت مستعدًا. اكتب شيئًا في الأسفل للبدء.',
|
||||
'home.statusBackendOnly': 'جارٍ إعادة الاتصال بالخادم… سيتوفر وكيلك قريبًا.',
|
||||
'home.statusCoreUnreachable':
|
||||
'العملية الأساسية المحلية لا تستجيب. قد تكون عملية OpenHuman في الخلفية قد تعطلت أو فشلت في البدء.',
|
||||
@@ -340,6 +339,10 @@ const messages: TranslationMap = {
|
||||
'routines.typeCommand': 'القيادة',
|
||||
'nav.routines': 'Routines',
|
||||
'chat.newThread': 'محادثة جديدة',
|
||||
'chat.newConversation': 'محادثة جديدة',
|
||||
'chat.newWindowWelcome1': 'مرحبًا، {name} 👋',
|
||||
'chat.newWindowWelcome2': 'لنبدأ العمل، {name} 🧑🍳.',
|
||||
'chat.newWindowWelcome3': 'حان وقت التركيز 🧘🏻',
|
||||
'chat.typeMessage': 'كيف يمكنني مساعدتك اليوم؟',
|
||||
'chat.send': 'إرسال الرسالة',
|
||||
'chat.parallelBranchHint': 'فرع متوازٍ — ⌘/Ctrl+Enter للإرسال',
|
||||
@@ -2840,8 +2843,7 @@ const messages: TranslationMap = {
|
||||
'home.banners.earlyBirdTitle': 'أول 1,000 مستخدم يحصلون على خصم 60٪.',
|
||||
'home.banners.earlyBirdUseCode': 'استخدم كود المبكر',
|
||||
'home.banners.getSubscription': 'احصل على اشتراك',
|
||||
'home.banners.promoCreditsBody': 'وصف رصيد العرض',
|
||||
'home.banners.promoCreditsTitle': '{amount}',
|
||||
'home.banners.promoCreditsBody': 'لديك {amount} من رصيد العرض. يمكنك',
|
||||
'home.banners.promoCreditsUsage': 'استخدام رصيد العرض',
|
||||
'intelligence.memoryChunk.detail.chunk': 'جزء',
|
||||
'intelligence.memoryChunk.detail.copyChunkId': 'نسخ معرف الجزء',
|
||||
|
||||
@@ -297,8 +297,7 @@ const messages: TranslationMap = {
|
||||
'home.greetingAfternoon': 'শুভ বিকেল',
|
||||
'home.greetingEvening': 'শুভ সন্ধ্যা',
|
||||
'home.askAssistant': 'আপনার অ্যাসিস্ট্যান্টকে যেকোনো কিছু জিজ্ঞেস করুন...',
|
||||
'home.statusOk':
|
||||
'আপনার ডিভাইস সংযুক্ত। সংযোগ সক্রিয় রাখতে অ্যাপটি চালু রাখুন। নিচের বাটন দিয়ে এজেন্টকে মেসেজ করুন।',
|
||||
'home.statusOk': 'আপনার সহকারী প্রস্তুত। শুরু করতে নিচে কিছু লিখুন।',
|
||||
'home.statusBackendOnly': 'ব্যাকএন্ডে পুনরায় সংযোগ হচ্ছে… আপনার এজেন্ট শীঘ্রই আবার পাওয়া যাবে।',
|
||||
'home.statusCoreUnreachable':
|
||||
'লোকাল কোর সাইডকার সাড়া দিচ্ছে না। OpenHuman ব্যাকগ্রাউন্ড প্রসেস ক্র্যাশ হয়েছে বা শুরু হয়নি।',
|
||||
@@ -344,6 +343,10 @@ const messages: TranslationMap = {
|
||||
'routines.typeCommand': 'কমান্ড',
|
||||
'nav.routines': 'Routines',
|
||||
'chat.newThread': 'নতুন থ্রেড',
|
||||
'chat.newConversation': 'নতুন কথোপকথন',
|
||||
'chat.newWindowWelcome1': 'স্বাগতম, {name} 👋',
|
||||
'chat.newWindowWelcome2': 'চলুন শুরু করি, {name} 🧑🍳.',
|
||||
'chat.newWindowWelcome3': 'মনোযোগ দেওয়ার সময় 🧘🏻',
|
||||
'chat.typeMessage': 'আজ আমি আপনাকে কীভাবে সাহায্য করতে পারি?',
|
||||
'chat.send': 'বার্তা পাঠান',
|
||||
'chat.parallelBranchHint': 'সমান্তরাল শাখা টাইপ করুন — পাঠাতে ⌘/Ctrl+Enter',
|
||||
@@ -2899,8 +2902,7 @@ const messages: TranslationMap = {
|
||||
'home.banners.earlyBirdTitle': 'প্রথম ১,০০০ ব্যবহারকারী ৬০% ছাড় পাবেন।',
|
||||
'home.banners.earlyBirdUseCode': 'আর্লি বার্ড কোড ব্যবহার করুন',
|
||||
'home.banners.getSubscription': 'সাবস্ক্রিপশন নিন',
|
||||
'home.banners.promoCreditsBody': 'প্রোমো ক্রেডিট বডি',
|
||||
'home.banners.promoCreditsTitle': '{amount}',
|
||||
'home.banners.promoCreditsBody': 'আপনার কাছে {amount} প্রোমো ক্রেডিট রয়েছে। আপনি',
|
||||
'home.banners.promoCreditsUsage': 'প্রোমো ক্রেডিট ব্যবহার',
|
||||
'intelligence.memoryChunk.detail.chunk': 'চাংক',
|
||||
'intelligence.memoryChunk.detail.copyChunkId': 'চাংক ID কপি করুন',
|
||||
|
||||
@@ -309,7 +309,7 @@ const messages: TranslationMap = {
|
||||
'home.greetingEvening': 'Guten Abend',
|
||||
'home.askAssistant': 'Frag deinen Assistenten etwas ...',
|
||||
'home.statusOk':
|
||||
'Dein Gerät ist verbunden. Lass die App laufen, um die Verbindung aufrechtzuerhalten. Sende deinem Agenten über die Schaltfläche unten eine Nachricht.',
|
||||
'Dein Assistent ist bereit, wenn du es bist. Schreib unten etwas, um loszulegen.',
|
||||
'home.statusBackendOnly':
|
||||
'Verbindung zum Backend wird wiederhergestellt. Dein Agent wird in Kürze wieder verfügbar sein.',
|
||||
'home.statusCoreUnreachable':
|
||||
@@ -356,6 +356,10 @@ const messages: TranslationMap = {
|
||||
'routines.typeCommand': 'Kommandant',
|
||||
'nav.routines': 'Routines',
|
||||
'chat.newThread': 'Neuer Thread',
|
||||
'chat.newConversation': 'Neue Unterhaltung',
|
||||
'chat.newWindowWelcome1': 'Willkommen, {name} 👋',
|
||||
'chat.newWindowWelcome2': 'Legen wir los, {name} 🧑🍳.',
|
||||
'chat.newWindowWelcome3': 'Zeit zum Fokussieren 🧘🏻',
|
||||
'chat.typeMessage': 'Wie kann ich dir heute helfen?',
|
||||
'chat.send': 'Nachricht senden',
|
||||
'chat.parallelBranchHint': 'Parallelen Zweig eingeben — ⌘/Strg+Enter zum Senden',
|
||||
@@ -2969,8 +2973,7 @@ const messages: TranslationMap = {
|
||||
'home.banners.earlyBirdTitle': 'Die ersten 1.000 Nutzer erhalten 60 % Rabatt.',
|
||||
'home.banners.earlyBirdUseCode': 'Frühbucher-Nutzungscode',
|
||||
'home.banners.getSubscription': 'Hol dir ein Abonnement',
|
||||
'home.banners.promoCreditsBody': 'Probiere OpenHuman aus und wenn du Lust auf mehr hast,',
|
||||
'home.banners.promoCreditsTitle': 'Du hast {amount} Werbeguthaben.',
|
||||
'home.banners.promoCreditsBody': 'Du hast {amount} an Promo-Guthaben. Du kannst',
|
||||
'home.banners.promoCreditsUsage': 'und erhalte 10x mehr Nutzung.',
|
||||
'intelligence.memoryChunk.detail.chunk': 'Brocken',
|
||||
'intelligence.memoryChunk.detail.copyChunkId': 'Chunk-ID kopieren',
|
||||
|
||||
+11
-9
@@ -314,8 +314,7 @@ const en: TranslationMap = {
|
||||
'home.greetingAfternoon': 'Good afternoon',
|
||||
'home.greetingEvening': 'Good evening',
|
||||
'home.askAssistant': 'Ask your assistant anything...',
|
||||
'home.statusOk':
|
||||
'Your device is connected. Keep the app running to keep the connection alive. Message your agent with the button below.',
|
||||
'home.statusOk': 'Your assistant is ready when you are. Type something below to get started.',
|
||||
'home.statusBackendOnly': 'Reconnecting to backend… your agent will be available again shortly.',
|
||||
'home.statusCoreUnreachable':
|
||||
"The OpenHuman core isn't responding. The background process may have crashed or failed to start.",
|
||||
@@ -365,6 +364,10 @@ const en: TranslationMap = {
|
||||
|
||||
// Chat / Conversations
|
||||
'chat.newThread': 'New thread',
|
||||
'chat.newConversation': 'New Conversation',
|
||||
'chat.newWindowWelcome1': 'Welcome, {name} 👋',
|
||||
'chat.newWindowWelcome2': "Let's cook, {name} 🧑🍳.",
|
||||
'chat.newWindowWelcome3': 'Time to Zone In 🧘🏻',
|
||||
'chat.typeMessage': 'How can I help you today?',
|
||||
'chat.send': 'Send message',
|
||||
'chat.parallelBranchHint': 'Type a parallel branch — ⌘/Ctrl+Enter to send',
|
||||
@@ -3065,7 +3068,7 @@ const en: TranslationMap = {
|
||||
'accounts.webviewHost.timeoutHint': 'Timeout hint',
|
||||
'app.connectionBadge.composio': 'Composio',
|
||||
'app.connectionBadge.messaging': 'Messaging',
|
||||
'app.connectionIndicator.connected': 'Connected to OpenHuman AI 🚀',
|
||||
'app.connectionIndicator.connected': 'Connected',
|
||||
'app.connectionIndicator.connecting': 'Connecting',
|
||||
'app.connectionIndicator.coreOffline': 'Core offline',
|
||||
'app.connectionIndicator.disconnected': 'Disconnected',
|
||||
@@ -3432,17 +3435,16 @@ const en: TranslationMap = {
|
||||
'Retry failed. Download the latest app build and try again.',
|
||||
'daemon.serviceBlockingGate.retrying': 'Retrying...',
|
||||
'daemon.serviceBlockingGate.title': 'OpenHuman core is unavailable',
|
||||
'home.banners.discordSubtitle': 'Get support, share feedback, and meet the community.',
|
||||
'home.banners.discordTitle': 'Join Our Discord',
|
||||
'home.banners.discordSubtitle': 'get support, share feedback, and meet the community.',
|
||||
'home.banners.discordTitle': 'Join our Discord',
|
||||
'home.banners.earlyBirdDismiss': 'Dismiss early bird banner',
|
||||
'home.banners.earlyBirdFirstSub': 'first subscription.',
|
||||
'home.banners.earlyBirdOn': 'Early bird on',
|
||||
'home.banners.earlyBirdTitle': 'The first 1,000 users get 60% off.',
|
||||
'home.banners.earlyBirdUseCode': 'Use code',
|
||||
'home.banners.getSubscription': 'Get a subscription',
|
||||
'home.banners.promoCreditsBody': 'in promo credits added to your account.',
|
||||
'home.banners.promoCreditsTitle': '{amount}',
|
||||
'home.banners.promoCreditsUsage': 'Apply them toward any AI workload.',
|
||||
'home.banners.getSubscription': 'get a subscription',
|
||||
'home.banners.promoCreditsBody': 'You have {amount} in promo credits. You can',
|
||||
'home.banners.promoCreditsUsage': 'and put them toward any AI workload.',
|
||||
'intelligence.memoryChunk.detail.chunk': 'Chunk',
|
||||
'intelligence.memoryChunk.detail.copyChunkId': 'Copy chunk id',
|
||||
'intelligence.memoryChunk.detail.embeddingInfo': 'bge-m3 1024dim',
|
||||
|
||||
@@ -309,8 +309,7 @@ const messages: TranslationMap = {
|
||||
'home.greetingAfternoon': 'Buenas tardes',
|
||||
'home.greetingEvening': 'Buenas noches',
|
||||
'home.askAssistant': 'Pregúntale lo que quieras a tu asistente...',
|
||||
'home.statusOk':
|
||||
'Tu dispositivo está conectado. Mantén la app abierta para conservar la conexión. Envíale un mensaje a tu agente con el botón de abajo.',
|
||||
'home.statusOk': 'Tu asistente está listo cuando tú lo estés. Escribe algo abajo para empezar.',
|
||||
'home.statusBackendOnly':
|
||||
'Reconectando al backend… tu agente estará disponible nuevamente en breve.',
|
||||
'home.statusCoreUnreachable':
|
||||
@@ -357,6 +356,10 @@ const messages: TranslationMap = {
|
||||
'routines.typeCommand': 'Comando',
|
||||
'nav.routines': 'Routines',
|
||||
'chat.newThread': 'Nuevo hilo',
|
||||
'chat.newConversation': 'Nueva conversación',
|
||||
'chat.newWindowWelcome1': 'Hola, {name} 👋',
|
||||
'chat.newWindowWelcome2': 'Manos a la obra, {name} 🧑🍳.',
|
||||
'chat.newWindowWelcome3': 'Hora de concentrarse 🧘🏻',
|
||||
'chat.typeMessage': '¿En qué puedo ayudarte hoy?',
|
||||
'chat.send': 'Enviar mensaje',
|
||||
'chat.parallelBranchHint': 'Escribe una rama paralela — ⌘/Ctrl+Enter para enviar',
|
||||
@@ -2950,8 +2953,7 @@ const messages: TranslationMap = {
|
||||
'home.banners.earlyBirdTitle': 'Los primeros 1.000 usuarios obtienen un 60% de descuento.',
|
||||
'home.banners.earlyBirdUseCode': 'Usar código early bird',
|
||||
'home.banners.getSubscription': 'obtener una suscripción',
|
||||
'home.banners.promoCreditsBody': 'Cuerpo de créditos promocionales',
|
||||
'home.banners.promoCreditsTitle': '{amount}',
|
||||
'home.banners.promoCreditsBody': 'Tienes {amount} en créditos promocionales. Puedes',
|
||||
'home.banners.promoCreditsUsage': 'Uso de créditos promocionales',
|
||||
'intelligence.memoryChunk.detail.chunk': 'Fragmento',
|
||||
'intelligence.memoryChunk.detail.copyChunkId': 'Copiar ID de fragmento',
|
||||
|
||||
@@ -310,7 +310,7 @@ const messages: TranslationMap = {
|
||||
'home.greetingEvening': 'Bonsoir',
|
||||
'home.askAssistant': "Pose n'importe quelle question à ton assistant…",
|
||||
'home.statusOk':
|
||||
"Ton appareil est connecté. Garde l'app ouverte pour maintenir la connexion. Envoie un message à ton agent avec le bouton ci-dessous.",
|
||||
'Ton assistant est prêt quand tu veux. Écris quelque chose ci-dessous pour commencer.',
|
||||
'home.statusBackendOnly':
|
||||
'Reconnexion au backend… ton agent sera de nouveau disponible dans quelques instants.',
|
||||
'home.statusCoreUnreachable':
|
||||
@@ -357,6 +357,10 @@ const messages: TranslationMap = {
|
||||
'routines.typeCommand': 'Commande',
|
||||
'nav.routines': 'Routines',
|
||||
'chat.newThread': 'Nouveau fil',
|
||||
'chat.newConversation': 'Nouvelle conversation',
|
||||
'chat.newWindowWelcome1': 'Bienvenue, {name} 👋',
|
||||
'chat.newWindowWelcome2': 'On y va, {name} 🧑🍳.',
|
||||
'chat.newWindowWelcome3': 'Place à la concentration 🧘🏻',
|
||||
'chat.typeMessage': "Comment puis-je t'aider aujourd'hui ?",
|
||||
'chat.send': 'Envoyer le message',
|
||||
'chat.parallelBranchHint': 'Saisir une branche parallèle — ⌘/Ctrl+Entrée pour envoyer',
|
||||
@@ -2964,8 +2968,7 @@ const messages: TranslationMap = {
|
||||
'home.banners.earlyBirdTitle': 'Les 1 000 premiers utilisateurs bénéficient de 60 % de remise.',
|
||||
'home.banners.earlyBirdUseCode': 'Utiliser le code early bird',
|
||||
'home.banners.getSubscription': 'obtenir un abonnement',
|
||||
'home.banners.promoCreditsBody': 'Corps des crédits promo',
|
||||
'home.banners.promoCreditsTitle': '{amount}',
|
||||
'home.banners.promoCreditsBody': 'Tu as {amount} de crédits promo. Tu peux',
|
||||
'home.banners.promoCreditsUsage': 'Utilisation des crédits promo',
|
||||
'intelligence.memoryChunk.detail.chunk': 'Segment',
|
||||
'intelligence.memoryChunk.detail.copyChunkId': "Copier l'identifiant du segment",
|
||||
|
||||
@@ -296,8 +296,7 @@ const messages: TranslationMap = {
|
||||
'home.greetingAfternoon': 'नमस्ते',
|
||||
'home.greetingEvening': 'शुभ संध्या',
|
||||
'home.askAssistant': 'असिस्टेंट से कुछ भी पूछें...',
|
||||
'home.statusOk':
|
||||
'आपका डिवाइस कनेक्टेड है। कनेक्शन बनाए रखने के लिए ऐप चलाते रहें। नीचे बटन से अपने एजेंट को मैसेज करें।',
|
||||
'home.statusOk': 'आपका सहायक तैयार है। शुरू करने के लिए नीचे कुछ लिखें।',
|
||||
'home.statusBackendOnly': 'बैकएंड से फिर से जुड़ रहे हैं… आपका एजेंट जल्द ही उपलब्ध होगा।',
|
||||
'home.statusCoreUnreachable':
|
||||
'लोकल कोर साइडकार रिस्पॉन्ड नहीं कर रहा। OpenHuman का बैकग्राउंड प्रोसेस क्रैश हो गया होगा या शुरू नहीं हो पाया।',
|
||||
@@ -343,6 +342,10 @@ const messages: TranslationMap = {
|
||||
'routines.typeCommand': 'कमान',
|
||||
'nav.routines': 'Routines',
|
||||
'chat.newThread': 'नई थ्रेड',
|
||||
'chat.newConversation': 'नई बातचीत',
|
||||
'chat.newWindowWelcome1': 'स्वागत है, {name} 👋',
|
||||
'chat.newWindowWelcome2': 'चलिए शुरू करें, {name} 🧑🍳.',
|
||||
'chat.newWindowWelcome3': 'ध्यान लगाने का समय 🧘🏻',
|
||||
'chat.typeMessage': 'आज मैं आपकी कैसे मदद कर सकता हूँ?',
|
||||
'chat.send': 'मैसेज भेजें',
|
||||
'chat.parallelBranchHint': 'समानांतर शाखा टाइप करें — भेजने के लिए ⌘/Ctrl+Enter',
|
||||
@@ -2900,8 +2903,7 @@ const messages: TranslationMap = {
|
||||
'home.banners.earlyBirdTitle': 'पहले 1,000 उपयोगकर्ताओं को 60% की छूट मिलती है।',
|
||||
'home.banners.earlyBirdUseCode': 'अर्ली बर्ड कोड का उपयोग करें',
|
||||
'home.banners.getSubscription': 'सदस्यता लें',
|
||||
'home.banners.promoCreditsBody': 'प्रोमो क्रेडिट विवरण',
|
||||
'home.banners.promoCreditsTitle': '{amount}',
|
||||
'home.banners.promoCreditsBody': 'आपके पास {amount} प्रोमो क्रेডিट हैं। आप',
|
||||
'home.banners.promoCreditsUsage': 'प्रोमो क्रेडिट उपयोग',
|
||||
'intelligence.memoryChunk.detail.chunk': 'चंक',
|
||||
'intelligence.memoryChunk.detail.copyChunkId': 'Chunk ID कॉपी करें',
|
||||
|
||||
@@ -299,8 +299,7 @@ const messages: TranslationMap = {
|
||||
'home.greetingAfternoon': 'Selamat siang',
|
||||
'home.greetingEvening': 'Selamat malam',
|
||||
'home.askAssistant': 'Tanyakan apa saja ke asisten Anda...',
|
||||
'home.statusOk':
|
||||
'Perangkat Anda terhubung. Biarkan aplikasi tetap berjalan agar koneksi tetap aktif. Kirim pesan ke agen Anda dengan tombol di bawah.',
|
||||
'home.statusOk': 'Asistenmu siap kapan pun kamu siap. Ketik sesuatu di bawah untuk memulai.',
|
||||
'home.statusBackendOnly':
|
||||
'Menghubungkan ulang ke backend... agen Anda akan segera tersedia lagi.',
|
||||
'home.statusCoreUnreachable':
|
||||
@@ -347,6 +346,10 @@ const messages: TranslationMap = {
|
||||
'routines.typeCommand': 'Perintah',
|
||||
'nav.routines': 'Routines',
|
||||
'chat.newThread': 'Thread baru',
|
||||
'chat.newConversation': 'Percakapan baru',
|
||||
'chat.newWindowWelcome1': 'Selamat datang, {name} 👋',
|
||||
'chat.newWindowWelcome2': 'Ayo mulai, {name} 🧑🍳.',
|
||||
'chat.newWindowWelcome3': 'Waktunya fokus 🧘🏻',
|
||||
'chat.typeMessage': 'Apa yang bisa saya bantu hari ini?',
|
||||
'chat.send': 'Kirim pesan',
|
||||
'chat.parallelBranchHint': 'Ketik cabang paralel — ⌘/Ctrl+Enter untuk mengirim',
|
||||
@@ -2904,8 +2907,7 @@ const messages: TranslationMap = {
|
||||
'home.banners.earlyBirdTitle': '1.000 pengguna pertama dapat diskon 60%.',
|
||||
'home.banners.earlyBirdUseCode': 'Gunakan kode early bird',
|
||||
'home.banners.getSubscription': 'dapatkan langganan',
|
||||
'home.banners.promoCreditsBody': 'Isi kredit promo',
|
||||
'home.banners.promoCreditsTitle': '{amount}',
|
||||
'home.banners.promoCreditsBody': 'Kamu punya {amount} kredit promo. Kamu bisa',
|
||||
'home.banners.promoCreditsUsage': 'Penggunaan kredit promo',
|
||||
'intelligence.memoryChunk.detail.chunk': 'Potongan',
|
||||
'intelligence.memoryChunk.detail.copyChunkId': 'Salin ID chunk',
|
||||
|
||||
@@ -307,7 +307,7 @@ const messages: TranslationMap = {
|
||||
'home.greetingEvening': 'Buonasera',
|
||||
'home.askAssistant': 'Chiedi qualsiasi cosa al tuo assistente...',
|
||||
'home.statusOk':
|
||||
"Il tuo dispositivo è connesso. Tieni l'app in esecuzione per mantenere la connessione attiva. Scrivi al tuo agente con il pulsante sotto.",
|
||||
'Il tuo assistente è pronto quando lo sei tu. Scrivi qualcosa qui sotto per iniziare.',
|
||||
'home.statusBackendOnly': 'Riconnessione al backend… il tuo agente sarà disponibile a breve.',
|
||||
'home.statusCoreUnreachable':
|
||||
'Il sidecar core locale non risponde. Il processo in background di OpenHuman potrebbe essersi bloccato o non essere partito.',
|
||||
@@ -353,6 +353,10 @@ const messages: TranslationMap = {
|
||||
'routines.typeCommand': 'Comando',
|
||||
'nav.routines': 'Routines',
|
||||
'chat.newThread': 'Nuovo thread',
|
||||
'chat.newConversation': 'Nuova conversazione',
|
||||
'chat.newWindowWelcome1': 'Benvenuto, {name} 👋',
|
||||
'chat.newWindowWelcome2': 'Mettiamoci al lavoro, {name} 🧑🍳.',
|
||||
'chat.newWindowWelcome3': 'È ora di concentrarsi 🧘🏻',
|
||||
'chat.typeMessage': 'Come posso aiutarti oggi?',
|
||||
'chat.send': 'Invia messaggio',
|
||||
'chat.parallelBranchHint': 'Digita un ramo parallelo — ⌘/Ctrl+Invio per inviare',
|
||||
@@ -2943,8 +2947,7 @@ const messages: TranslationMap = {
|
||||
'home.banners.earlyBirdTitle': 'I primi 1.000 utenti ottengono il 60% di sconto.',
|
||||
'home.banners.earlyBirdUseCode': 'Codice early bird',
|
||||
'home.banners.getSubscription': 'ottieni un abbonamento',
|
||||
'home.banners.promoCreditsBody': 'Corpo crediti promozionali',
|
||||
'home.banners.promoCreditsTitle': '{amount}',
|
||||
'home.banners.promoCreditsBody': 'Hai {amount} di crediti promozionali. Puoi',
|
||||
'home.banners.promoCreditsUsage': 'Uso crediti promozionali',
|
||||
'intelligence.memoryChunk.detail.chunk': 'Pezzo',
|
||||
'intelligence.memoryChunk.detail.copyChunkId': 'Copia ID chunk',
|
||||
|
||||
@@ -296,8 +296,7 @@ const messages: TranslationMap = {
|
||||
'home.greetingAfternoon': '좋은 오후입니다',
|
||||
'home.greetingEvening': '좋은 저녁입니다',
|
||||
'home.askAssistant': '어시스턴트에게 무엇이든 물어보세요...',
|
||||
'home.statusOk':
|
||||
'기기가 연결되었습니다. 연결을 유지하려면 앱을 계속 실행해 주세요. 아래 버튼으로 에이전트에게 메시지를 보내세요.',
|
||||
'home.statusOk': '어시스턴트가 준비되어 있어요. 아래에 무언가 입력해 시작하세요.',
|
||||
'home.statusBackendOnly':
|
||||
'백엔드에 다시 연결하는 중입니다… 곧 에이전트를 다시 사용할 수 있습니다.',
|
||||
'home.statusCoreUnreachable':
|
||||
@@ -344,6 +343,10 @@ const messages: TranslationMap = {
|
||||
'routines.typeCommand': '명령',
|
||||
'nav.routines': 'Routines',
|
||||
'chat.newThread': '새 스레드',
|
||||
'chat.newConversation': '새 대화',
|
||||
'chat.newWindowWelcome1': '환영해요, {name} 👋',
|
||||
'chat.newWindowWelcome2': '시작해 볼까요, {name} 🧑🍳.',
|
||||
'chat.newWindowWelcome3': '집중할 시간이에요 🧘🏻',
|
||||
'chat.typeMessage': '오늘 무엇을 도와드릴까요?',
|
||||
'chat.send': '메시지 보내기',
|
||||
'chat.parallelBranchHint': '병렬 분기 입력 — 보내려면 ⌘/Ctrl+Enter',
|
||||
@@ -2875,8 +2878,7 @@ const messages: TranslationMap = {
|
||||
'home.banners.earlyBirdTitle': '첫 1,000명의 사용자는 60% 할인을 받습니다.',
|
||||
'home.banners.earlyBirdUseCode': '얼리버드 코드 사용',
|
||||
'home.banners.getSubscription': '구독하기',
|
||||
'home.banners.promoCreditsBody': 'OpenHuman을 사용해 보고, 더 필요할 때',
|
||||
'home.banners.promoCreditsTitle': '{amount}의 프로모션 크레딧이 있습니다.',
|
||||
'home.banners.promoCreditsBody': '프로모션 크레딧 {amount}이(가) 있어요. 다음을 할 수 있어요:',
|
||||
'home.banners.promoCreditsUsage': '10배 더 많은 사용량을 받으세요.',
|
||||
'intelligence.memoryChunk.detail.chunk': '청크',
|
||||
'intelligence.memoryChunk.detail.copyChunkId': '청크 ID 복사',
|
||||
|
||||
@@ -301,8 +301,7 @@ const messages: TranslationMap = {
|
||||
'home.greetingAfternoon': 'Dobre popołudnie',
|
||||
'home.greetingEvening': 'Dobry wieczór',
|
||||
'home.askAssistant': 'Zapytaj asystenta o cokolwiek...',
|
||||
'home.statusOk':
|
||||
'Twoje urządzenie jest połączone. Utrzymuj aplikację uruchomioną, aby zachować połączenie. Wyślij wiadomość do agenta przyciskiem poniżej.',
|
||||
'home.statusOk': 'Twój asystent jest gotowy, gdy tylko zechcesz. Napisz coś poniżej, aby zacząć.',
|
||||
'home.statusBackendOnly':
|
||||
'Ponowne łączenie z backendem… Twój agent będzie dostępny ponownie za chwilę.',
|
||||
'home.statusCoreUnreachable':
|
||||
@@ -349,6 +348,10 @@ const messages: TranslationMap = {
|
||||
'routines.typeCommand': 'Polecenie',
|
||||
'nav.routines': 'Routines',
|
||||
'chat.newThread': 'Nowy wątek',
|
||||
'chat.newConversation': 'Nowa rozmowa',
|
||||
'chat.newWindowWelcome1': 'Witaj, {name} 👋',
|
||||
'chat.newWindowWelcome2': 'Do dzieła, {name} 🧑🍳.',
|
||||
'chat.newWindowWelcome3': 'Czas się skupić 🧘🏻',
|
||||
'chat.typeMessage': 'Jak mogę ci dziś pomóc?',
|
||||
'chat.send': 'Wyślij wiadomość',
|
||||
'chat.parallelBranchHint': 'Wpisz równoległą gałąź — ⌘/Ctrl+Enter, aby wysłać',
|
||||
@@ -2934,8 +2937,7 @@ const messages: TranslationMap = {
|
||||
'home.banners.earlyBirdTitle': 'Pierwszych 1000 użytkowników otrzymuje 60% zniżki.',
|
||||
'home.banners.earlyBirdUseCode': 'Użyj kodu early bird',
|
||||
'home.banners.getSubscription': 'wykup subskrypcję',
|
||||
'home.banners.promoCreditsBody': 'Przetestuj OpenHuman, a gdy będziesz gotowy(a) na więcej,',
|
||||
'home.banners.promoCreditsTitle': 'Masz {amount} kredytów promocyjnych.',
|
||||
'home.banners.promoCreditsBody': 'Masz {amount} w kredytach promo. Możesz',
|
||||
'home.banners.promoCreditsUsage': 'i uzyskaj 10x więcej użycia.',
|
||||
'intelligence.memoryChunk.detail.chunk': 'Fragment',
|
||||
'intelligence.memoryChunk.detail.copyChunkId': 'Skopiuj ID fragmentu',
|
||||
|
||||
@@ -309,7 +309,7 @@ const messages: TranslationMap = {
|
||||
'home.greetingEvening': 'Boa noite',
|
||||
'home.askAssistant': 'Pergunte qualquer coisa ao seu assistente...',
|
||||
'home.statusOk':
|
||||
'Seu dispositivo está conectado. Mantenha o app aberto para manter a conexão ativa. Envie uma mensagem ao seu agente com o botão abaixo.',
|
||||
'Seu assistente está pronto quando você estiver. Escreva algo abaixo para começar.',
|
||||
'home.statusBackendOnly':
|
||||
'Reconectando ao backend… seu agente estará disponível novamente em breve.',
|
||||
'home.statusCoreUnreachable':
|
||||
@@ -356,6 +356,10 @@ const messages: TranslationMap = {
|
||||
'routines.typeCommand': 'Comando',
|
||||
'nav.routines': 'Routines',
|
||||
'chat.newThread': 'Nova conversa',
|
||||
'chat.newConversation': 'Nova conversa',
|
||||
'chat.newWindowWelcome1': 'Bem-vindo, {name} 👋',
|
||||
'chat.newWindowWelcome2': 'Mãos à obra, {name} 🧑🍳.',
|
||||
'chat.newWindowWelcome3': 'Hora de focar 🧘🏻',
|
||||
'chat.typeMessage': 'Como posso ajudá-lo hoje?',
|
||||
'chat.send': 'Enviar mensagem',
|
||||
'chat.parallelBranchHint': 'Digite uma ramificação paralela — ⌘/Ctrl+Enter para enviar',
|
||||
@@ -2948,8 +2952,7 @@ const messages: TranslationMap = {
|
||||
'home.banners.earlyBirdTitle': 'Os primeiros 1.000 usuários ganham 60% de desconto.',
|
||||
'home.banners.earlyBirdUseCode': 'Usar código early bird',
|
||||
'home.banners.getSubscription': 'fazer uma assinatura',
|
||||
'home.banners.promoCreditsBody': 'Corpo dos créditos promocionais',
|
||||
'home.banners.promoCreditsTitle': '{amount}',
|
||||
'home.banners.promoCreditsBody': 'Você tem {amount} em créditos promocionais. Você pode',
|
||||
'home.banners.promoCreditsUsage': 'Uso dos créditos promocionais',
|
||||
'intelligence.memoryChunk.detail.chunk': 'Bloco',
|
||||
'intelligence.memoryChunk.detail.copyChunkId': 'Copiar ID do bloco',
|
||||
|
||||
@@ -303,7 +303,7 @@ const messages: TranslationMap = {
|
||||
'home.greetingEvening': 'Добрый вечер',
|
||||
'home.askAssistant': 'Спроси ассистента о чём угодно...',
|
||||
'home.statusOk':
|
||||
'Устройство подключено. Не закрывай приложение, чтобы поддерживать соединение. Отправь сообщение агенту с помощью кнопки ниже.',
|
||||
'Ваш ассистент готов, когда вы будете готовы. Напишите что-нибудь ниже, чтобы начать.',
|
||||
'home.statusBackendOnly': 'Переподключение к серверу… агент скоро снова будет доступен.',
|
||||
'home.statusCoreUnreachable':
|
||||
'Локальный процесс OpenHuman не отвечает. Возможно, он завис или не запустился.',
|
||||
@@ -349,6 +349,10 @@ const messages: TranslationMap = {
|
||||
'routines.typeCommand': 'Команда',
|
||||
'nav.routines': 'Routines',
|
||||
'chat.newThread': 'Новый чат',
|
||||
'chat.newConversation': 'Новый разговор',
|
||||
'chat.newWindowWelcome1': 'Привет, {name} 👋',
|
||||
'chat.newWindowWelcome2': 'Поехали, {name} 🧑🍳.',
|
||||
'chat.newWindowWelcome3': 'Время сосредоточиться 🧘🏻',
|
||||
'chat.typeMessage': 'Чем я могу помочь тебе сегодня?',
|
||||
'chat.send': 'Отправить сообщение',
|
||||
'chat.parallelBranchHint': 'Введите параллельную ветку — ⌘/Ctrl+Enter для отправки',
|
||||
@@ -2921,8 +2925,7 @@ const messages: TranslationMap = {
|
||||
'home.banners.earlyBirdTitle': 'Первые 1 000 пользователей получают скидку 60%.',
|
||||
'home.banners.earlyBirdUseCode': 'Используйте промокод для раннего доступа',
|
||||
'home.banners.getSubscription': 'оформить подписку',
|
||||
'home.banners.promoCreditsBody': 'Описание промо-кредитов',
|
||||
'home.banners.promoCreditsTitle': '{amount}',
|
||||
'home.banners.promoCreditsBody': 'У вас есть {amount} промокредитов. Вы можете',
|
||||
'home.banners.promoCreditsUsage': 'Использование промо-кредитов',
|
||||
'intelligence.memoryChunk.detail.chunk': 'Блок',
|
||||
'intelligence.memoryChunk.detail.copyChunkId': 'Скопировать ID блока',
|
||||
|
||||
@@ -288,7 +288,7 @@ const messages: TranslationMap = {
|
||||
'home.greetingAfternoon': '下午好',
|
||||
'home.greetingEvening': '晚上好',
|
||||
'home.askAssistant': '向你的助手提问...',
|
||||
'home.statusOk': '你的设备已连接。保持应用运行以维持连接,通过下方按钮向你的智能体发送消息。',
|
||||
'home.statusOk': '你的助手已准备就绪。在下方输入内容即可开始。',
|
||||
'home.statusBackendOnly': '正在重新连接后端…你的智能体很快将再次可用。',
|
||||
'home.statusCoreUnreachable': '本地核心 sidecar 无响应。OpenHuman 后台进程可能已崩溃或未能启动。',
|
||||
'home.statusInternetOffline': '你的设备当前处于离线状态。请检查网络或重启应用以重新连接。',
|
||||
@@ -329,6 +329,10 @@ const messages: TranslationMap = {
|
||||
'routines.typeCommand': '命令',
|
||||
'nav.routines': 'Routines',
|
||||
'chat.newThread': '新对话',
|
||||
'chat.newConversation': '新对话',
|
||||
'chat.newWindowWelcome1': '欢迎,{name} 👋',
|
||||
'chat.newWindowWelcome2': '开干吧,{name} 🧑🍳。',
|
||||
'chat.newWindowWelcome3': '是时候专注了 🧘🏻',
|
||||
'chat.typeMessage': '今天我能帮您做什么?',
|
||||
'chat.send': '发送',
|
||||
'chat.parallelBranchHint': '输入并行分支 — ⌘/Ctrl+Enter 发送',
|
||||
@@ -2758,8 +2762,7 @@ const messages: TranslationMap = {
|
||||
'home.banners.earlyBirdTitle': '前 1,000 名用户享 6 折优惠。',
|
||||
'home.banners.earlyBirdUseCode': '使用早鸟码',
|
||||
'home.banners.getSubscription': '获取订阅',
|
||||
'home.banners.promoCreditsBody': '你的促销配额已到账',
|
||||
'home.banners.promoCreditsTitle': '{amount}',
|
||||
'home.banners.promoCreditsBody': '你有 {amount} 促销额度。你可以',
|
||||
'home.banners.promoCreditsUsage': '查看用量详情',
|
||||
'intelligence.memoryChunk.detail.chunk': '片段',
|
||||
'intelligence.memoryChunk.detail.copyChunkId': '复制片段 ID',
|
||||
|
||||
+97
-97
@@ -3,6 +3,7 @@ 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 { SidebarContent } from '../components/layout/shell/SidebarSlot';
|
||||
import {
|
||||
CustomGifMascot,
|
||||
getMascotPalette,
|
||||
@@ -74,6 +75,7 @@ const RailButton = ({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
title={tooltip}
|
||||
data-analytics-id={analyticsId}
|
||||
// Issue #1284 — `hover:z-50` lifts the entire button (and its tooltip
|
||||
// child) above sibling rail buttons during hover. Without it, the
|
||||
@@ -83,7 +85,7 @@ const RailButton = ({
|
||||
// tooltip rectangle. Belt-and-suspenders for the active-button case
|
||||
// too, where ring-2 + bg-primary-50 don't transform but the lifted
|
||||
// z still helps tooltips render cleanly above neighbours.
|
||||
className={`group relative flex h-11 w-11 items-center justify-center rounded-xl transition-all hover:z-50 ${
|
||||
className={`group relative flex h-9 w-9 flex-none items-center justify-center rounded-lg transition-all hover:z-50 ${
|
||||
active
|
||||
? 'bg-primary-50 ring-2 ring-primary-500'
|
||||
: 'hover:bg-stone-100 dark:hover:bg-neutral-800/60 hover:scale-105'
|
||||
@@ -95,17 +97,9 @@ const RailButton = ({
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
) : null}
|
||||
{/* Issue #1284 — tooltip sits BELOW the icon (`top-full`) so it stays
|
||||
inside the HTML-only rail region. The native CEF webview is
|
||||
composited above the HTML layer to the right of the rail, so a
|
||||
right-anchored tooltip is hidden behind the webview the moment a
|
||||
provider is open and DOM z-index can't lift it. Below-icon keeps
|
||||
the tooltip near the cursor and never blocks the icon being
|
||||
hovered (it briefly overlays the next icon down, which clears as
|
||||
soon as the user moves the cursor). */}
|
||||
<span className="pointer-events-none absolute left-1/2 top-full mt-1 -translate-x-1/2 whitespace-nowrap rounded-md bg-stone-900 px-2 py-1 text-xs text-white opacity-0 shadow-md transition-opacity group-hover:opacity-100 z-50">
|
||||
{tooltip}
|
||||
</span>
|
||||
{/* Tooltip is the native `title` (set above): a custom absolute tooltip got
|
||||
clipped by the rail's horizontal-scroll overflow, and the native title
|
||||
isn't subject to overflow clipping or CEF webview compositing. */}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -336,102 +330,108 @@ 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"
|
||||
// `h-full` makes this page fill the shell's content box edge-to-edge.
|
||||
className="relative flex h-full overflow-hidden"
|
||||
data-testid="accounts-page"
|
||||
data-analytics-id="chat-right-sidebar">
|
||||
{/* Narrow icon rail — always rendered. */}
|
||||
<aside className="z-30 flex w-16 flex-none flex-col items-center gap-2 bg-white/60 dark:bg-neutral-900/60 py-3 backdrop-blur-md my-3 ml-3 rounded-2xl border border-stone-200/70 dark:border-neutral-800/70 shadow-soft">
|
||||
<RailButton
|
||||
active={isAgentSelected}
|
||||
onClick={selectAgent}
|
||||
tooltip={t('accounts.agent')}
|
||||
analyticsId="chat-right-sidebar-agent">
|
||||
<AgentIcon className="h-9 w-9 rounded-lg bg-white dark:bg-neutral-200" />
|
||||
</RailButton>
|
||||
|
||||
{accounts.map(acct => (
|
||||
{/* App rail — projected into the root sidebar's dynamic region as a compact
|
||||
horizontal row above the thread search (order-0 sits above the thread
|
||||
list, which Conversations projects as order-1). */}
|
||||
<SidebarContent>
|
||||
<div
|
||||
data-testid="accounts-app-rail"
|
||||
data-analytics-id="chat-app-rail"
|
||||
className="scrollbar-hide order-0 flex flex-none items-center gap-1.5 overflow-x-auto overflow-y-hidden border-b border-stone-100 px-2 py-2 dark:border-neutral-800">
|
||||
<RailButton
|
||||
key={acct.id}
|
||||
active={acct.id === selectedId}
|
||||
onClick={() => selectAccount(acct.id)}
|
||||
onContextMenu={e => openContextMenu(acct.id, e)}
|
||||
tooltip={acct.label}
|
||||
analyticsId={`chat-right-sidebar-account-${acct.provider}`}
|
||||
badge={unreadByAccount[acct.id]}>
|
||||
<ProviderIcon provider={acct.provider} className="h-8 w-8 rounded-md" />
|
||||
active={isAgentSelected}
|
||||
onClick={selectAgent}
|
||||
tooltip={t('accounts.agent')}
|
||||
analyticsId="chat-app-rail-agent">
|
||||
<AgentIcon className="h-5 w-5 rounded-md bg-white dark:bg-neutral-200" />
|
||||
</RailButton>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
trackEvent('tauri_browser_click', {
|
||||
surface: 'chat_right_sidebar',
|
||||
action: 'open_add_account',
|
||||
provider: 'none',
|
||||
});
|
||||
setAddOpen(true);
|
||||
}}
|
||||
data-analytics-id="chat-right-sidebar-add-account"
|
||||
data-testid="accounts-add-button"
|
||||
className="group relative mt-2 flex h-11 w-11 items-center justify-center rounded-xl border border-dashed border-stone-300 dark:border-neutral-700 text-stone-400 dark:text-neutral-500 hover:z-50 hover:bg-stone-50 dark:hover:bg-neutral-800/60 hover:text-stone-600 dark:hover:text-neutral-300"
|
||||
aria-label={t('accounts.addAccount')}>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{/* Issue #1284 — see RailButton for why the tooltip sits below
|
||||
the icon instead of to the right. */}
|
||||
<span className="pointer-events-none absolute left-1/2 top-full mt-1 -translate-x-1/2 whitespace-nowrap rounded-md bg-stone-900 px-2 py-1 text-xs text-white opacity-0 shadow-md transition-opacity group-hover:opacity-100 z-50">
|
||||
{t('accounts.addAccount')}
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
{accounts.map(acct => (
|
||||
<RailButton
|
||||
key={acct.id}
|
||||
active={acct.id === selectedId}
|
||||
onClick={() => selectAccount(acct.id)}
|
||||
onContextMenu={e => openContextMenu(acct.id, e)}
|
||||
tooltip={acct.label}
|
||||
analyticsId={`chat-app-rail-account-${acct.provider}`}
|
||||
badge={unreadByAccount[acct.id]}>
|
||||
<ProviderIcon provider={acct.provider} className="h-5 w-5 rounded" />
|
||||
</RailButton>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
trackEvent('tauri_browser_click', {
|
||||
surface: 'chat_app_rail',
|
||||
action: 'open_add_account',
|
||||
provider: 'none',
|
||||
});
|
||||
setAddOpen(true);
|
||||
}}
|
||||
data-analytics-id="chat-app-rail-add-account"
|
||||
data-testid="accounts-add-button"
|
||||
className="group relative flex h-9 w-9 flex-none items-center justify-center rounded-xl border border-dashed border-stone-300 text-stone-400 hover:bg-stone-50 hover:text-stone-600 dark:border-neutral-700 dark:text-neutral-500 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-300"
|
||||
aria-label={t('accounts.addAccount')}
|
||||
title={t('accounts.addAccount')}>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</SidebarContent>
|
||||
|
||||
{/* "Talk to Tiny" face-mode toggle — hidden (kept for potential re-enable). */}
|
||||
|
||||
{/* 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 ? (
|
||||
<>
|
||||
{/* 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>
|
||||
)}
|
||||
{/* Main pane. In face mode (agent selected) it's a horizontal split with
|
||||
the mascot panel. Otherwise the agent chat is ALWAYS mounted — so the
|
||||
thread sidebar it projects stays consistent regardless of which app is
|
||||
selected — and a selected app's webview fills the pane edge-to-edge on
|
||||
top of it. */}
|
||||
{isAgentSelected && faceMode ? (
|
||||
<main className="flex min-w-0 flex-1 flex-row gap-3">
|
||||
<div className="flex min-h-0 w-[360px] flex-none flex-col">
|
||||
<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>
|
||||
{/* 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} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-stone-400 dark:text-neutral-500">
|
||||
{t('accounts.noAccounts')}
|
||||
<FaceModePanel />
|
||||
</main>
|
||||
) : (
|
||||
<main className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Agent chat — kept mounted even while a webview app is shown so its
|
||||
thread sidebar projection persists. `min-h-0` lets the message list
|
||||
own the scroll instead of pushing the composer off-screen. */}
|
||||
<div
|
||||
className={`min-h-0 flex-1 overflow-hidden ${isAgentSelected ? '' : 'invisible'}`}
|
||||
aria-hidden={!isAgentSelected}>
|
||||
<AgentChatPanel />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Selected connected app — fills the main content fully (no padding
|
||||
or margins) on top of the hidden agent chat. */}
|
||||
{!isAgentSelected && active && (
|
||||
<div className="absolute inset-0">
|
||||
<WebviewHost accountId={active.id} provider={active.provider} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAgentSelected && !active && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-stone-400 dark:text-neutral-500">
|
||||
{t('accounts.noAccounts')}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)}
|
||||
|
||||
<AddAccountModal
|
||||
open={addOpen}
|
||||
|
||||
+27
-31
@@ -15,7 +15,7 @@ import { MemorySourcesRegistry } from '../components/intelligence/MemorySourcesR
|
||||
import { MemoryTreeStatusPanel } from '../components/intelligence/MemoryTreeStatusPanel';
|
||||
import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import PanelPage from '../components/layout/PanelPage';
|
||||
import TwoPanelLayout from '../components/layout/TwoPanelLayout';
|
||||
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
|
||||
import TwoPaneNav from '../components/layout/TwoPaneNav';
|
||||
import { SettingsLayoutProvider } from '../components/settings/layout/SettingsLayoutContext';
|
||||
import AnalysisViewsPanel from '../components/settings/panels/AnalysisViewsPanel';
|
||||
@@ -139,17 +139,9 @@ export default function Brain() {
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<TwoPanelLayout
|
||||
id="brain"
|
||||
// Max-width applied once to the whole panel (sidebar + content) and
|
||||
// centered, matching the settings two-pane shell.
|
||||
className="mx-auto h-full w-full max-w-6xl p-4 pt-6"
|
||||
defaultSidebarVisible
|
||||
defaultSidebarWidth={210}
|
||||
minSidebarWidth={170}
|
||||
maxSidebarWidth={320}
|
||||
seamless
|
||||
sidebar={
|
||||
{/* The Brain navigation lives in the root app sidebar's dynamic region. */}
|
||||
<SidebarContent>
|
||||
<div className="h-full overflow-hidden">
|
||||
<TwoPaneNav
|
||||
ariaLabel={t('nav.brain')}
|
||||
selected={activeTab}
|
||||
@@ -225,31 +217,35 @@ export default function Brain() {
|
||||
},
|
||||
]}
|
||||
header={
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base font-bold text-stone-900 dark:text-neutral-100">
|
||||
{t('nav.brain')}
|
||||
</h1>
|
||||
<p className="mt-0.5 text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
{t('brain.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="min-w-0 text-[11px] text-stone-500 dark:text-neutral-400">
|
||||
{t('brain.subtitle')}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
}>
|
||||
</div>
|
||||
</SidebarContent>
|
||||
<div className="mx-auto h-full w-full max-w-5xl">
|
||||
{/* Knowledge & Memory panels relocated from Settings are themselves
|
||||
PanelPage panels (description, no title; the back button hides
|
||||
because the Brain sidebar owns navigation here), so they fill the
|
||||
content pane and own their own scroll directly. */}
|
||||
{KNOWLEDGE_TABS.has(activeTab) ? (
|
||||
<SettingsLayoutProvider value={{ inTwoPaneShell: true }}>
|
||||
{/* Distinct tab query key so the embedded Intelligence panel's
|
||||
internal tab switches don't overwrite Brain's own
|
||||
`?tab=intelligence` and unmount it. */}
|
||||
{activeTab === 'intelligence' && <Intelligence tabParamKey="itab" />}
|
||||
{activeTab === 'memory-data' && <MemoryDataPanel />}
|
||||
{activeTab === 'memory-debug' && <MemoryDebugPanel />}
|
||||
{activeTab === 'analysis-views' && <AnalysisViewsPanel />}
|
||||
</SettingsLayoutProvider>
|
||||
// Knowledge subpages were orphaned flush on the shell — give them a
|
||||
// card surface (the bespoke graph/sources/etc. tabs keep their own
|
||||
// scaffold below and stay flush).
|
||||
<div className="h-full p-4">
|
||||
<div className="h-full overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-soft dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<SettingsLayoutProvider value={{ inTwoPaneShell: true }}>
|
||||
{/* Distinct tab query key so the embedded Intelligence panel's
|
||||
internal tab switches don't overwrite Brain's own
|
||||
`?tab=intelligence` and unmount it. */}
|
||||
{activeTab === 'intelligence' && <Intelligence tabParamKey="itab" />}
|
||||
{activeTab === 'memory-data' && <MemoryDataPanel />}
|
||||
{activeTab === 'memory-debug' && <MemoryDebugPanel />}
|
||||
{activeTab === 'analysis-views' && <AnalysisViewsPanel />}
|
||||
</SettingsLayoutProvider>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Bespoke tabs share the standard scaffold: a single scrolling body,
|
||||
// all custom controls live inside it.
|
||||
@@ -316,7 +312,7 @@ export default function Brain() {
|
||||
</div>
|
||||
</PanelPage>
|
||||
)}
|
||||
</TwoPanelLayout>
|
||||
</div>
|
||||
|
||||
<ToastContainer notifications={toasts} onRemove={removeToast} />
|
||||
</div>
|
||||
|
||||
+200
-253
@@ -9,9 +9,10 @@ import ApprovalRequestCard from '../components/chat/ApprovalRequestCard';
|
||||
import ArtifactCard from '../components/chat/ArtifactCard';
|
||||
import ChatComposer from '../components/chat/ChatComposer';
|
||||
import ChatFilesChip from '../components/chat/ChatFilesChip';
|
||||
import ChatNewWindowHero from '../components/chat/ChatNewWindowHero';
|
||||
import ComposerTokenStats from '../components/chat/ComposerTokenStats';
|
||||
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
|
||||
import TwoPanelLayout, { useTwoPanelLayout } from '../components/layout/TwoPanelLayout';
|
||||
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
|
||||
import PillTabBar from '../components/PillTabBar';
|
||||
import UpsellBanner from '../components/upsell/UpsellBanner';
|
||||
import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDismissState';
|
||||
@@ -32,7 +33,6 @@ import { applyOpenRouterFreeModels } from '../services/api/openrouterFreeModels'
|
||||
import { threadApi } from '../services/api/threadApi';
|
||||
import { chatCancel, chatSend, useRustChat } from '../services/chatService';
|
||||
import { callCoreRpc } from '../services/coreRpcClient';
|
||||
import { store } from '../store';
|
||||
import {
|
||||
loadAgentProfiles,
|
||||
selectActiveAgentProfileId,
|
||||
@@ -60,7 +60,6 @@ import {
|
||||
persistReaction,
|
||||
setSelectedThread,
|
||||
THREAD_NOT_FOUND_MESSAGE,
|
||||
updateThreadTitle,
|
||||
} from '../store/threadSlice';
|
||||
import type { ConfirmationModal as ConfirmationModalType } from '../types/intelligence';
|
||||
import type { ThreadMessage } from '../types/thread';
|
||||
@@ -141,6 +140,15 @@ interface ConversationsProps {
|
||||
* Used by the mascot tab so the only interaction is voice.
|
||||
*/
|
||||
composer?: 'text' | 'mic-cloud';
|
||||
/**
|
||||
* Project the thread list into the root sidebar's dynamic region even in the
|
||||
* `sidebar` variant. Page variant always projects it; this lets an embedded
|
||||
* instance (e.g. the Human page's right-rail chat) surface the user's threads
|
||||
* in the left sidebar while keeping the chat itself on the right. The list
|
||||
* and the chat share the same selection state, so clicking a thread switches
|
||||
* the embedded conversation.
|
||||
*/
|
||||
projectThreadList?: boolean;
|
||||
}
|
||||
|
||||
// Stable empty reference so the `activeThreadIds` selector returns the same
|
||||
@@ -195,6 +203,7 @@ export function formatThreadLoadError(err: unknown): string {
|
||||
const Conversations = ({
|
||||
variant = 'page',
|
||||
composer: composerProp = 'text',
|
||||
projectThreadList = false,
|
||||
}: ConversationsProps = {}) => {
|
||||
const [composerOverride, setComposerOverride] = useState<'mic-cloud' | 'text' | null>(null);
|
||||
const composer = composerOverride ?? composerProp;
|
||||
@@ -296,10 +305,6 @@ const Conversations = ({
|
||||
);
|
||||
const rustChat = useRustChat();
|
||||
const [reactionPickerMsgId, setReactionPickerMsgId] = useState<string | null>(null);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [editTitleValue, setEditTitleValue] = useState('');
|
||||
const editTitleInputRef = useRef<HTMLInputElement>(null);
|
||||
const ignoreNextTitleBlurRef = useRef(false);
|
||||
|
||||
const {
|
||||
teamUsage,
|
||||
@@ -427,28 +432,6 @@ const Conversations = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEditTitle = () => {
|
||||
if (!selectedThreadId) return;
|
||||
const thr = threads.find(t => t.id === selectedThreadId);
|
||||
setEditTitleValue(thr?.title ?? '');
|
||||
ignoreNextTitleBlurRef.current = true;
|
||||
setEditingTitle(true);
|
||||
const scheduleSelect = window.requestAnimationFrame ?? window.setTimeout;
|
||||
scheduleSelect(() => {
|
||||
editTitleInputRef.current?.select();
|
||||
ignoreNextTitleBlurRef.current = false;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCommitTitle = () => {
|
||||
const trimmed = editTitleValue.trim();
|
||||
setEditingTitle(false);
|
||||
if (!selectedThreadId || !trimmed) return;
|
||||
const currentTitle = threads.find(t => t.id === selectedThreadId)?.title?.trim();
|
||||
if (trimmed === currentTitle) return;
|
||||
void dispatch(updateThreadTitle({ threadId: selectedThreadId, title: trimmed }));
|
||||
};
|
||||
|
||||
const handleSelectAgentProfile = async (profileId: string) => {
|
||||
try {
|
||||
await dispatch(selectAgentProfile(profileId)).unwrap();
|
||||
@@ -464,7 +447,6 @@ const Conversations = ({
|
||||
.unwrap()
|
||||
.then(data => {
|
||||
if (cancelled) return;
|
||||
const threadStateForSelect = store.getState().thread;
|
||||
// Match the sidebar's default General filter here so initial/resume
|
||||
// selection can't auto-pick a thread hidden by the selected tab.
|
||||
const visibleThreads = data.threads.filter(t => isThreadVisibleInTab(t, GENERAL_TAB_VALUE));
|
||||
@@ -489,19 +471,15 @@ const Conversations = ({
|
||||
void dispatch(loadThreadMessages(openThread.id));
|
||||
return;
|
||||
}
|
||||
if (visibleThreads.length > 0) {
|
||||
// Prefer the thread the user was last viewing (persisted across
|
||||
// reloads via redux-persist on the `thread` slice). Only fall
|
||||
// through to "most recent" if that thread no longer exists
|
||||
// server-side (deleted, purged, or different user) — or is now
|
||||
// hidden because it's a worker thread.
|
||||
const persistedId = threadStateForSelect.selectedThreadId;
|
||||
const resumeId =
|
||||
persistedId && visibleThreads.some(t => t.id === persistedId)
|
||||
? persistedId
|
||||
: visibleThreads[0].id;
|
||||
dispatch(setSelectedThread(resumeId));
|
||||
void dispatch(loadThreadMessages(resumeId));
|
||||
// Default landing is a fresh "new window" (the merged Home surface) —
|
||||
// we no longer resume the last conversation on open. Reuse an existing
|
||||
// empty thread if one is lying around so repeated opens don't pile up
|
||||
// blank threads; otherwise create a new one. Past conversations stay
|
||||
// reachable from the thread list (clicking one selects it directly).
|
||||
const emptyThread = visibleThreads.find(t => (t.messageCount ?? 0) === 0);
|
||||
if (emptyThread) {
|
||||
dispatch(setSelectedThread(emptyThread.id));
|
||||
void dispatch(loadThreadMessages(emptyThread.id));
|
||||
} else {
|
||||
void handleCreateNewThread();
|
||||
}
|
||||
@@ -1454,11 +1432,12 @@ const Conversations = ({
|
||||
labelTabs.find(tab => tab.value === selectedLabel)?.label ?? selectedLabel;
|
||||
|
||||
const isSidebar = variant === 'sidebar';
|
||||
// Chat thread sidebar visibility/width are owned by the reusable
|
||||
// TwoPanelLayout (persisted per-user in the `layout` slice under id `chat`).
|
||||
// The hook lets this header's hamburger toggle the same persisted state.
|
||||
const { sidebarVisible: chatSidebarVisible, toggleSidebar: toggleChatSidebar } =
|
||||
useTwoPanelLayout('chat', { sidebarVisible: false });
|
||||
// "New window" = the merged Home surface: a page-variant chat whose selected
|
||||
// thread has no messages yet. We show the greeting + banners hero above a
|
||||
// centered composer; the moment the first message lands, hasVisibleMessages
|
||||
// flips true and this collapses back to the normal conversation layout.
|
||||
const isNewWindow =
|
||||
!isSidebar && !isLoadingMessages && !messagesError && !hasVisibleMessages && !hasTaskBoard;
|
||||
|
||||
// Stable title resolver used by both the sidebar thread list and the header.
|
||||
const resolveThreadDisplayTitle = (threadId: string | null): string => {
|
||||
@@ -1541,10 +1520,32 @@ const Conversations = ({
|
||||
items={labelTabs}
|
||||
selected={selectedLabel}
|
||||
onChange={setSelectedLabel}
|
||||
containerClassName="flex flex-wrap gap-1 py-1"
|
||||
itemClassName="px-2"
|
||||
containerClassName="scrollbar-hide flex flex-nowrap gap-1 overflow-x-auto py-1"
|
||||
itemClassName="flex-none whitespace-nowrap px-2"
|
||||
/>
|
||||
</div>
|
||||
{/* New conversation — a subtle, centered thread-style row (not a loud
|
||||
button), below the pills and above the thread list. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="new-thread-button"
|
||||
data-analytics-id="chat-sidebar-new-thread"
|
||||
onClick={() => void handleCreateNewThread()}
|
||||
title={t('chat.newThreadShortcut')}
|
||||
className="group w-full cursor-pointer border-b border-stone-100/60 opacity-50 px-3 py-2 transition-colors hover:bg-stone-50 dark:border-neutral-800/60 dark:hover:bg-neutral-800/60">
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<svg
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-stone-500 dark:text-neutral-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span className="truncate text-xs text-stone-700 dark:text-neutral-200">
|
||||
{t('chat.newConversation')}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{visibleThreads.length === 0 ? (
|
||||
<p className="px-4 py-6 text-xs text-stone-400 dark:text-neutral-500 text-center">
|
||||
@@ -1641,196 +1642,17 @@ const Conversations = ({
|
||||
isSidebar
|
||||
? // Embedded variant keeps its own flush styling (no TwoPanelLayout).
|
||||
'flex-1 flex flex-col min-w-0 bg-white dark:bg-neutral-900 border-l border-stone-200 dark:border-neutral-800 overflow-hidden'
|
||||
: // Page variant: card background / rounded corners come from the
|
||||
// TwoPanelLayout pane wrapper.
|
||||
'flex-1 flex flex-col min-w-0'
|
||||
: // Page variant: flush over the shell background. `relative` anchors
|
||||
// the absolutely-positioned floating composer.
|
||||
'relative flex-1 flex flex-col min-w-0'
|
||||
}>
|
||||
{/* Chat header — only shown in page mode; the sidebar embed uses the
|
||||
parent page's chrome instead. Hidden entirely during welcome
|
||||
lockdown (#883) so the onboarding chat is just the conversation
|
||||
with no chrome around it. */}
|
||||
{!isSidebar && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 border-b border-stone-100 dark:border-neutral-800"
|
||||
data-walkthrough="chat-agent-panel">
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-header-toggle-sidebar"
|
||||
onClick={toggleChatSidebar}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800/60 text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 dark:hover:text-neutral-200 transition-colors"
|
||||
title={chatSidebarVisible ? t('chat.hideSidebar') : t('chat.showSidebar')}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
{selectedThreadParent ? (
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-header-back-to-parent-thread"
|
||||
onClick={() => {
|
||||
dispatch(setSelectedThread(selectedThreadParent.id));
|
||||
void dispatch(loadThreadMessages(selectedThreadParent.id));
|
||||
}}
|
||||
className="self-start flex items-center gap-1 text-[11px] font-medium text-primary-600 hover:text-primary-700 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-300 rounded -mx-1 px-1"
|
||||
data-testid="worker-thread-back-to-parent">
|
||||
<span aria-hidden="true">←</span>
|
||||
<span className="truncate max-w-[16rem]">
|
||||
{t('chat.backToThread').replace('{title}', selectedThreadParent.title)}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
{editingTitle ? (
|
||||
<input
|
||||
ref={editTitleInputRef}
|
||||
value={editTitleValue}
|
||||
onChange={e => setEditTitleValue(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleCommitTitle();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingTitle(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (ignoreNextTitleBlurRef.current) {
|
||||
ignoreNextTitleBlurRef.current = false;
|
||||
return;
|
||||
}
|
||||
handleCommitTitle();
|
||||
}}
|
||||
aria-label={t('chat.editThreadTitle')}
|
||||
className="h-5 text-sm font-medium text-stone-700 dark:text-neutral-200 bg-transparent border-b border-primary-400 outline-none w-full min-w-0 leading-none py-0"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 group/title min-w-0">
|
||||
<h3 className="text-sm font-medium text-stone-700 dark:text-neutral-200 truncate">
|
||||
{resolveThreadDisplayTitle(selectedThreadId)}
|
||||
</h3>
|
||||
{selectedThreadId && (
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-header-edit-thread-title"
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
handleStartEditTitle();
|
||||
}}
|
||||
onClick={handleStartEditTitle}
|
||||
aria-label={t('chat.editThreadTitle')}
|
||||
title={t('chat.editThreadTitle')}
|
||||
className="opacity-0 group-hover/title:opacity-100 flex-shrink-0 w-5 h-5 flex items-center justify-center rounded hover:bg-stone-100 dark:hover:bg-neutral-800 text-stone-400 dark:text-neutral-500 hover:text-stone-600 dark:hover:text-neutral-300 transition-all">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{resolvedModel && (
|
||||
<span className="text-[10px] text-stone-400 dark:text-neutral-500 leading-none">
|
||||
{resolvedModel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
className="flex items-center h-7 rounded-full border border-stone-200 dark:border-neutral-700 bg-stone-100 dark:bg-neutral-800 p-0.5"
|
||||
role="radiogroup"
|
||||
aria-label={t('chat.agentProfile.label')}>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selectedAgentProfileId === 'default'}
|
||||
data-analytics-id="chat-header-mode-quick"
|
||||
onClick={() => void handleSelectAgentProfile('default')}
|
||||
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-all ${
|
||||
selectedAgentProfileId === 'default'
|
||||
? 'bg-white dark:bg-neutral-600 text-stone-800 dark:text-neutral-100 shadow-sm'
|
||||
: 'text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200'
|
||||
}`}>
|
||||
{t('chat.agentProfile.quick')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selectedAgentProfileId === 'reasoning'}
|
||||
data-analytics-id="chat-header-mode-reasoning"
|
||||
onClick={() => void handleSelectAgentProfile('reasoning')}
|
||||
className={`px-2.5 py-0.5 rounded-full text-xs font-medium transition-all ${
|
||||
selectedAgentProfileId === 'reasoning'
|
||||
? 'bg-white dark:bg-neutral-600 text-stone-800 dark:text-neutral-100 shadow-sm'
|
||||
: 'text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200'
|
||||
}`}>
|
||||
{t('chat.agentProfile.reasoning')}
|
||||
</button>
|
||||
</div>
|
||||
{(selectedThreadId ?? firstActiveThreadId) && (
|
||||
<ChatFilesChip threadId={(selectedThreadId ?? firstActiveThreadId) as string} />
|
||||
)}
|
||||
{/* Gated on selectedThreadId alone (not the firstActiveThreadId
|
||||
fallback): the panel/badge derive from selectedThreadToolTimeline,
|
||||
which is [] unless a thread is actually selected, so showing the
|
||||
icon for the fallback would render an always-empty panel. */}
|
||||
{selectedThreadId && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="background-processes-toggle"
|
||||
data-analytics-id="chat-header-background-processes"
|
||||
onClick={() => setShowBackgroundProcesses(true)}
|
||||
aria-label={t('conversations.backgroundTasks.title')}
|
||||
title={
|
||||
backgroundProcesses.length > 0
|
||||
? t('conversations.backgroundTasks.titleWithCount').replace(
|
||||
'{count}',
|
||||
String(backgroundProcesses.length)
|
||||
)
|
||||
: t('conversations.backgroundTasks.title')
|
||||
}
|
||||
className="relative flex h-7 w-7 items-center justify-center rounded-lg text-stone-500 hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-200 transition-colors">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
|
||||
/>
|
||||
</svg>
|
||||
{runningBackgroundCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-500 px-0.5 text-[9px] font-semibold leading-none text-white">
|
||||
{runningBackgroundCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="new-thread-button"
|
||||
data-analytics-id="chat-header-new-thread"
|
||||
onClick={() => void handleCreateNewThread()}
|
||||
className="px-2.5 py-1 rounded-lg text-xs font-medium text-white bg-primary-500 hover:bg-primary-600 shadow-sm transition-colors"
|
||||
title={t('chat.newThreadShortcut')}>
|
||||
{t('chat.new')}
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex-1 overflow-y-auto px-5 py-4 bg-[#f6f6f6] dark:bg-neutral-950">
|
||||
// Full-width scroll (scrollbar hugs the window edge); inner content is
|
||||
// centered and width-capped per branch below.
|
||||
className="flex-1 overflow-y-auto">
|
||||
{isLoadingMessages ? (
|
||||
<div className="space-y-4">
|
||||
<div className="mx-auto w-full max-w-[48.75rem] space-y-4 px-5 py-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className={`flex ${i % 2 === 0 ? 'justify-start' : 'justify-end'}`}>
|
||||
<div
|
||||
@@ -1870,7 +1692,10 @@ const Conversations = ({
|
||||
</button>
|
||||
</div>
|
||||
) : hasVisibleMessages || hasTaskBoard ? (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className={`mx-auto w-full max-w-[48.75rem] space-y-3 px-5 pt-4 ${
|
||||
isSidebar ? 'pb-4' : 'pb-32'
|
||||
}`}>
|
||||
{selectedTaskBoard && hasTaskBoard && (
|
||||
<TaskKanbanBoard
|
||||
board={selectedTaskBoard}
|
||||
@@ -2327,6 +2152,8 @@ const Conversations = ({
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
) : isNewWindow ? (
|
||||
<ChatNewWindowHero />
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center h-full">
|
||||
<p className="text-sm text-stone-600 dark:text-neutral-300">{t('chat.noMessages')}</p>
|
||||
@@ -2334,7 +2161,26 @@ const Conversations = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 border-t border-stone-200 dark:border-neutral-800 px-4 py-3">
|
||||
{/* Full-width fade so messages dissolve into the background (black/white
|
||||
per theme) behind the floating composer. Page variant only. */}
|
||||
{!isSidebar && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-28 bg-gradient-to-t from-white via-white/90 to-transparent dark:from-black dark:via-black/90"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
data-walkthrough="home-cta"
|
||||
// Page variant: float at the bottom (absolute) over the fade; centered +
|
||||
// width-capped to match the messages. `z-20` keeps it above messages
|
||||
// that would otherwise paint over it while scrolling. Sidebar embed
|
||||
// keeps the in-flow composer.
|
||||
className={
|
||||
isSidebar
|
||||
? 'mx-auto w-full max-w-[48.75rem] flex-shrink-0 px-4 py-3'
|
||||
: 'absolute inset-x-0 bottom-0 z-20 mx-auto w-full max-w-[48.75rem] px-4 pb-4 pt-6'
|
||||
}>
|
||||
<>
|
||||
{isNearLimit &&
|
||||
!isAtLimit &&
|
||||
@@ -2602,7 +2448,100 @@ const Conversations = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<ComposerTokenStats />
|
||||
{/* Worker-thread back-to-parent breadcrumb (page variant) — its own line. */}
|
||||
{!isSidebar && selectedThreadParent && (
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-header-back-to-parent-thread"
|
||||
onClick={() => {
|
||||
dispatch(setSelectedThread(selectedThreadParent.id));
|
||||
void dispatch(loadThreadMessages(selectedThreadParent.id));
|
||||
}}
|
||||
className="mt-2 flex items-center gap-1 rounded px-1 text-[11px] font-medium text-primary-600 hover:text-primary-700 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-300"
|
||||
data-testid="worker-thread-back-to-parent">
|
||||
<span aria-hidden="true">←</span>
|
||||
<span className="max-w-[16rem] truncate">
|
||||
{t('chat.backToThread').replace('{title}', selectedThreadParent.title)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Model + token stats (left) and the quick/reasoning toggle + files
|
||||
chip (right) share one line. */}
|
||||
<div
|
||||
className="mt-2 flex items-center justify-between gap-2"
|
||||
data-walkthrough="chat-agent-panel">
|
||||
<ComposerTokenStats model={resolvedModel} />
|
||||
{!isSidebar && (
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<div
|
||||
className="flex h-7 items-center rounded-full border border-stone-200 bg-stone-100 p-0.5 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
role="radiogroup"
|
||||
aria-label={t('chat.agentProfile.label')}>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selectedAgentProfileId === 'default'}
|
||||
data-analytics-id="chat-header-mode-quick"
|
||||
onClick={() => void handleSelectAgentProfile('default')}
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium transition-all ${
|
||||
selectedAgentProfileId === 'default'
|
||||
? 'bg-white text-stone-800 shadow-sm dark:bg-neutral-600 dark:text-neutral-100'
|
||||
: 'text-stone-500 hover:text-stone-700 dark:text-neutral-400 dark:hover:text-neutral-200'
|
||||
}`}>
|
||||
{t('chat.agentProfile.quick')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selectedAgentProfileId === 'reasoning'}
|
||||
data-analytics-id="chat-header-mode-reasoning"
|
||||
onClick={() => void handleSelectAgentProfile('reasoning')}
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium transition-all ${
|
||||
selectedAgentProfileId === 'reasoning'
|
||||
? 'bg-white text-stone-800 shadow-sm dark:bg-neutral-600 dark:text-neutral-100'
|
||||
: 'text-stone-500 hover:text-stone-700 dark:text-neutral-400 dark:hover:text-neutral-200'
|
||||
}`}>
|
||||
{t('chat.agentProfile.reasoning')}
|
||||
</button>
|
||||
</div>
|
||||
{selectedThreadId && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="background-processes-toggle"
|
||||
data-analytics-id="chat-header-background-processes"
|
||||
onClick={() => setShowBackgroundProcesses(true)}
|
||||
aria-label={t('conversations.backgroundTasks.title')}
|
||||
title={
|
||||
backgroundProcesses.length > 0
|
||||
? t('conversations.backgroundTasks.titleWithCount').replace(
|
||||
'{count}',
|
||||
String(backgroundProcesses.length)
|
||||
)
|
||||
: t('conversations.backgroundTasks.title')
|
||||
}
|
||||
className="relative flex h-7 w-7 items-center justify-center rounded-lg text-stone-500 transition-colors hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-200">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
|
||||
/>
|
||||
</svg>
|
||||
{runningBackgroundCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-500 px-0.5 text-[9px] font-semibold leading-none text-white">
|
||||
{runningBackgroundCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{(selectedThreadId ?? firstActiveThreadId) && (
|
||||
<ChatFilesChip threadId={(selectedThreadId ?? firstActiveThreadId) as string} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2612,23 +2551,31 @@ const Conversations = ({
|
||||
className={
|
||||
isSidebar
|
||||
? 'h-full relative z-10 flex overflow-hidden'
|
||||
: 'h-full relative z-10 flex justify-center overflow-hidden p-4 pt-6'
|
||||
: 'h-full relative z-10 flex justify-center overflow-hidden bg-white/70 dark:bg-black/40'
|
||||
}>
|
||||
{isSidebar ? (
|
||||
mainPanel
|
||||
) : (
|
||||
// Max-width is applied to the whole two-pane layout (sidebar + chat
|
||||
// together) and centered, rather than capping the chat pane alone. The
|
||||
// cap widens when the threads pane is shown so the chat keeps a
|
||||
// comfortable reading width in both states.
|
||||
<TwoPanelLayout
|
||||
id="chat"
|
||||
className={`h-full w-full ${chatSidebarVisible ? 'max-w-5xl' : 'max-w-2xl'}`}
|
||||
sidebar={threadSidebar}
|
||||
contentClassName="flex"
|
||||
defaultSidebarVisible={false}>
|
||||
<>
|
||||
{projectThreadList && (
|
||||
<SidebarContent>
|
||||
<div className="order-1 flex h-full min-h-0 flex-col overflow-hidden">
|
||||
{threadSidebar}
|
||||
</div>
|
||||
</SidebarContent>
|
||||
)}
|
||||
{mainPanel}
|
||||
</TwoPanelLayout>
|
||||
</>
|
||||
) : (
|
||||
// The thread list always lives in the root app sidebar's dynamic region
|
||||
// (order-1 so any app rail projected by the parent sits above it). The
|
||||
// chat pane keeps a comfortable, centered reading width.
|
||||
<>
|
||||
<SidebarContent>
|
||||
<div className="order-1 flex h-full min-h-0 flex-col overflow-hidden">
|
||||
{threadSidebar}
|
||||
</div>
|
||||
</SidebarContent>
|
||||
<div className="flex h-full w-full">{mainPanel}</div>
|
||||
</>
|
||||
)}
|
||||
<ConfirmationModal
|
||||
modal={deleteModal}
|
||||
|
||||
@@ -130,6 +130,14 @@ const Home = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex flex-col items-center justify-center p-4">
|
||||
{/* Welcome title */}
|
||||
<h1 className="min-h-[3.5rem] text-32l font-bold text-stone-900 dark:text-neutral-100 text-center">
|
||||
{typedWelcome}
|
||||
<span aria-hidden="true" className="ml-0.5 inline-block text-primary-500 animate-pulse">
|
||||
|
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div className="max-w-md w-full">
|
||||
{shouldShowBudgetCompletedMessage && (
|
||||
<UsageLimitBanner
|
||||
@@ -206,14 +214,6 @@ const Home = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Welcome title */}
|
||||
<h1 className="min-h-[3.5rem] text-32l font-bold text-stone-900 dark:text-neutral-100 text-center">
|
||||
{typedWelcome}
|
||||
<span aria-hidden="true" className="ml-0.5 inline-block text-primary-500 animate-pulse">
|
||||
|
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Connection status */}
|
||||
<div className="flex justify-center mb-3">
|
||||
<ConnectionIndicator />
|
||||
|
||||
+26
-29
@@ -12,7 +12,7 @@ import {
|
||||
import EmptyStateCard from '../components/EmptyStateCard';
|
||||
import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import PanelPage from '../components/layout/PanelPage';
|
||||
import TwoPanelLayout from '../components/layout/TwoPanelLayout';
|
||||
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
|
||||
import TwoPaneNav from '../components/layout/TwoPaneNav';
|
||||
import { SettingsLayoutProvider } from '../components/settings/layout/SettingsLayoutContext';
|
||||
import AIPanel from '../components/settings/panels/AIPanel';
|
||||
@@ -813,26 +813,13 @@ export default function Skills() {
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<TwoPanelLayout
|
||||
id="connections"
|
||||
// Max-width applied once to the whole panel (sidebar + content) and
|
||||
// centered, matching the settings two-pane shell.
|
||||
className="mx-auto h-full w-full max-w-6xl p-4 pt-6"
|
||||
defaultSidebarVisible
|
||||
defaultSidebarWidth={210}
|
||||
minSidebarWidth={170}
|
||||
maxSidebarWidth={320}
|
||||
seamless
|
||||
sidebar={
|
||||
{/* The Connections navigation lives in the root app sidebar's dynamic region. */}
|
||||
<SidebarContent>
|
||||
<div className="h-full overflow-hidden">
|
||||
<TwoPaneNav
|
||||
ariaLabel={t('nav.connections')}
|
||||
selected={activeTab}
|
||||
onSelect={value => handleTabChange(value as ConnectionsTab)}
|
||||
header={
|
||||
<h1 className="text-base font-bold text-stone-900 dark:text-neutral-100">
|
||||
{t('nav.connections')}
|
||||
</h1>
|
||||
}
|
||||
groups={[
|
||||
{
|
||||
label: t('connections.groups.integrations'),
|
||||
@@ -912,23 +899,30 @@ export default function Skills() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}>
|
||||
</div>
|
||||
</SidebarContent>
|
||||
<div className="mx-auto h-full w-full max-w-5xl">
|
||||
{/* Intelligence panels relocated from Settings are themselves PanelPage
|
||||
panels (description, no title; the back button hides because the
|
||||
Connections sidebar owns navigation), so they fill the content pane
|
||||
and own their scroll directly. */}
|
||||
{INTELLIGENCE_TABS.has(activeTab) ? (
|
||||
<SettingsLayoutProvider value={{ inTwoPaneShell: true }}>
|
||||
{activeTab === 'llm' && <AIPanel />}
|
||||
{activeTab === 'voice' && <VoicePanel />}
|
||||
{activeTab === 'embeddings' && <EmbeddingsPanel />}
|
||||
{activeTab === 'search' && <SearchPanel />}
|
||||
{activeTab === 'composio-key' && <ComposioPanel />}
|
||||
</SettingsLayoutProvider>
|
||||
// API-keys / provider panels were orphaned flush on the shell — give
|
||||
// them a card surface (the integrations/skills grids below already
|
||||
// have their own card layouts, so they stay flush).
|
||||
<div className="h-full p-4">
|
||||
<div className="h-full overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-soft dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<SettingsLayoutProvider value={{ inTwoPaneShell: true }}>
|
||||
{activeTab === 'llm' && <AIPanel />}
|
||||
{activeTab === 'voice' && <VoicePanel />}
|
||||
{activeTab === 'embeddings' && <EmbeddingsPanel />}
|
||||
{activeTab === 'search' && <SearchPanel />}
|
||||
{activeTab === 'composio-key' && <ComposioPanel />}
|
||||
</SettingsLayoutProvider>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PanelPage
|
||||
description={activeTab === 'composio' ? t('skills.integrationsSubtitle') : undefined}
|
||||
contentClassName="p-4">
|
||||
<PanelPage contentClassName="p-4">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4">
|
||||
{/* <div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
@@ -1046,6 +1040,9 @@ export default function Skills() {
|
||||
className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 shadow-soft animate-fade-up"
|
||||
data-walkthrough="skills-grid"
|
||||
data-testid="composio-integrations-card">
|
||||
<p className="px-1 pb-3 text-xs leading-relaxed text-stone-500 dark:text-neutral-400">
|
||||
{t('skills.integrationsSubtitle')}
|
||||
</p>
|
||||
{showLocalComposioApiKeyBanner && (
|
||||
<ComposioApiKeyEmptyState
|
||||
onOpenSettings={() => handleTabChange('composio-key')}
|
||||
@@ -1134,7 +1131,7 @@ export default function Skills() {
|
||||
</div>
|
||||
</PanelPage>
|
||||
)}
|
||||
</TwoPanelLayout>
|
||||
</div>
|
||||
|
||||
{channelModalDef && (
|
||||
<ChannelSetupModal definition={channelModalDef} onClose={() => setChannelModalDef(null)} />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SidebarSlotOutlet, SidebarSlotProvider } from '../../components/layout/shell/SidebarSlot';
|
||||
import agentProfileReducer from '../../store/agentProfileSlice';
|
||||
import chatRuntimeReducer from '../../store/chatRuntimeSlice';
|
||||
import socketReducer from '../../store/socketSlice';
|
||||
@@ -128,6 +129,10 @@ vi.mock('../../services/api/agentProfilesApi', () => ({
|
||||
|
||||
vi.mock('../../hooks/useUsageState', () => ({ useUsageState: mockUseUsageState }));
|
||||
|
||||
// The new-window hero pulls useUser/useCoreState; stub it (these tests assert
|
||||
// the composer/attachments, not the empty-state hero).
|
||||
vi.mock('../../components/chat/ChatNewWindowHero', () => ({ default: () => null }));
|
||||
|
||||
vi.mock('../../utils/config', async importActual => ({
|
||||
...(await importActual<typeof import('../../utils/config')>()),
|
||||
CHAT_ATTACHMENTS_ENABLED: true,
|
||||
@@ -247,7 +252,10 @@ async function renderWithSelectedThread() {
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={['/conversations']}>
|
||||
<Conversations />
|
||||
<SidebarSlotProvider>
|
||||
<SidebarSlotOutlet />
|
||||
<Conversations />
|
||||
</SidebarSlotProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
@@ -564,7 +572,10 @@ describe('Conversations — attachment feature', () => {
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<Conversations />
|
||||
<SidebarSlotProvider>
|
||||
<SidebarSlotOutlet />
|
||||
<Conversations />
|
||||
</SidebarSlotProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
@@ -613,7 +624,10 @@ describe('Conversations — attachment feature', () => {
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<Conversations />
|
||||
<SidebarSlotProvider>
|
||||
<SidebarSlotOutlet />
|
||||
<Conversations />
|
||||
</SidebarSlotProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SidebarSlotOutlet, SidebarSlotProvider } from '../../components/layout/shell/SidebarSlot';
|
||||
import { threadApi } from '../../services/api/threadApi';
|
||||
import { chatSend } from '../../services/chatService';
|
||||
import { CoreRpcError } from '../../services/coreRpcClient';
|
||||
@@ -127,6 +128,11 @@ vi.mock('../../services/api/openrouterFreeModels', () => ({
|
||||
|
||||
vi.mock('../../hooks/useUsageState', () => ({ useUsageState: mockUseUsageState }));
|
||||
|
||||
// The new-window hero pulls useUser/useCoreState; stub it so the page renders
|
||||
// without a CoreStateProvider (these tests assert the sidebar/composer, not the
|
||||
// empty-state hero).
|
||||
vi.mock('../../components/chat/ChatNewWindowHero', () => ({ default: () => null }));
|
||||
|
||||
vi.mock('../../store/socketSelectors', () => ({
|
||||
selectSocketStatus: (state: { socket?: { byUser?: Record<string, { status: string }> } }) =>
|
||||
state.socket?.byUser?.__pending__?.status ?? 'disconnected',
|
||||
@@ -202,7 +208,12 @@ async function renderConversations(preload: Record<string, unknown> = {}) {
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={['/conversations']}>
|
||||
<Conversations />
|
||||
{/* The thread sidebar is projected into the root sidebar slot, so the
|
||||
page needs a provider + outlet for that portal to mount in tests. */}
|
||||
<SidebarSlotProvider>
|
||||
<SidebarSlotOutlet />
|
||||
<Conversations />
|
||||
</SidebarSlotProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
@@ -210,12 +221,9 @@ async function renderConversations(preload: Record<string, unknown> = {}) {
|
||||
return store;
|
||||
}
|
||||
|
||||
/** Click the sidebar toggle so the thread list becomes visible. */
|
||||
/** The thread sidebar is always projected now (no toggle); just flush effects. */
|
||||
async function openSidebar() {
|
||||
const toggleBtn = screen.getByTitle('Show sidebar');
|
||||
await act(async () => {
|
||||
fireEvent.click(toggleBtn);
|
||||
});
|
||||
await act(async () => {});
|
||||
}
|
||||
|
||||
// Default empty state
|
||||
@@ -328,28 +336,6 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
expect(screen.getByText('General')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('restores and updates the persisted thread sidebar visibility', async () => {
|
||||
let renderedStore: ReturnType<typeof buildStore> | undefined;
|
||||
await act(async () => {
|
||||
renderedStore = await renderConversations({
|
||||
thread: emptyThreadState,
|
||||
// Sidebar visibility now lives in the reusable `layout` slice (id `chat`).
|
||||
layout: { panels: { chat: { sidebarVisible: true, sidebarWidth: 256 } } },
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByText('General')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTitle('Hide sidebar'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('General')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(renderedStore?.getState().layout.panels.chat.sidebarVisible).toBe(false);
|
||||
});
|
||||
|
||||
// Covers line 941 empty branch
|
||||
it('shows the General empty message when the default bucket has no threads', async () => {
|
||||
await act(async () => {
|
||||
@@ -1371,7 +1357,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
|
||||
expect(screen.queryByRole('tab', { name: 'Briefing' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('tab', { name: 'Notification' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('tab', { name: 'Workers' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('tablist')).toHaveClass('flex-wrap');
|
||||
expect(screen.getByRole('tablist')).toHaveClass('flex-nowrap');
|
||||
});
|
||||
|
||||
it('starts with the "General" tab selected', async () => {
|
||||
@@ -1684,139 +1670,6 @@ describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)',
|
||||
});
|
||||
});
|
||||
|
||||
describe('Conversations — thread title editing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseUsageState.mockReturnValue({
|
||||
teamUsage: null,
|
||||
currentPlan: null,
|
||||
currentTier: 'FREE' as const,
|
||||
isFreeTier: true,
|
||||
usagePct: 0,
|
||||
isNearLimit: false,
|
||||
isAtLimit: false,
|
||||
isBudgetExhausted: false,
|
||||
shouldShowBudgetCompletedMessage: false,
|
||||
isLoading: false,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
|
||||
});
|
||||
|
||||
it('shows pencil icon on hover and enters edit mode on click', async () => {
|
||||
const thread = makeThread({ id: 'edit-title-thread', title: 'Original Title' });
|
||||
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({
|
||||
thread: selectedThreadState(thread),
|
||||
socket: socketState('connected'),
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByText('Original Title')).toBeInTheDocument();
|
||||
|
||||
const editBtn = screen.getByRole('button', { name: 'Edit thread title' });
|
||||
expect(editBtn).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(editBtn);
|
||||
});
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Edit thread title' });
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveValue('Original Title');
|
||||
});
|
||||
|
||||
it('commits edited title on Enter and dispatches updateThreadTitle', async () => {
|
||||
const thread = makeThread({ id: 'commit-title-thread', title: 'Old Title' });
|
||||
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
|
||||
(threadApi.updateTitle as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...thread,
|
||||
title: 'New Title',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({
|
||||
thread: selectedThreadState(thread),
|
||||
socket: socketState('connected'),
|
||||
});
|
||||
});
|
||||
|
||||
const editBtn = screen.getByRole('button', { name: 'Edit thread title' });
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(editBtn);
|
||||
});
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Edit thread title' });
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: 'New Title' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(threadApi.updateTitle).toHaveBeenCalledWith('commit-title-thread', 'New Title');
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels editing on Escape without dispatching', async () => {
|
||||
const thread = makeThread({ id: 'cancel-title-thread', title: 'Keep Me' });
|
||||
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({
|
||||
thread: selectedThreadState(thread),
|
||||
socket: socketState('connected'),
|
||||
});
|
||||
});
|
||||
|
||||
const editBtn = screen.getByRole('button', { name: 'Edit thread title' });
|
||||
await act(async () => {
|
||||
fireEvent.click(editBtn);
|
||||
});
|
||||
|
||||
const input = screen.getByLabelText('Edit thread title');
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: 'Changed' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
});
|
||||
|
||||
expect(screen.getByText('Keep Me')).toBeInTheDocument();
|
||||
expect(threadApi.updateTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not dispatch when title is empty after trim', async () => {
|
||||
const thread = makeThread({ id: 'empty-title-thread', title: 'Has Title' });
|
||||
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
|
||||
|
||||
await act(async () => {
|
||||
await renderConversations({
|
||||
thread: selectedThreadState(thread),
|
||||
socket: socketState('connected'),
|
||||
});
|
||||
});
|
||||
|
||||
const editBtn = screen.getByRole('button', { name: 'Edit thread title' });
|
||||
await act(async () => {
|
||||
fireEvent.click(editBtn);
|
||||
});
|
||||
|
||||
const input = screen.getByLabelText('Edit thread title');
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: ' ' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
});
|
||||
|
||||
expect(threadApi.updateTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Conversations — open-session resume (View work)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -15,9 +15,9 @@ describe('themeSlice', () => {
|
||||
expect(state.fontSize).toBe('medium');
|
||||
});
|
||||
|
||||
it('defaults assistant message rendering to bubbles', () => {
|
||||
it('defaults assistant message rendering to plain text', () => {
|
||||
const state = themeReducer(undefined, { type: '@@INIT' });
|
||||
expect(state.agentMessageViewMode).toBe('bubbles');
|
||||
expect(state.agentMessageViewMode).toBe('text');
|
||||
});
|
||||
|
||||
it('updates fontSize via setFontSize', () => {
|
||||
@@ -37,7 +37,7 @@ describe('themeSlice', () => {
|
||||
mode: 'dark',
|
||||
tabBarLabels: 'always',
|
||||
fontSize: 'xlarge',
|
||||
agentMessageViewMode: 'bubbles',
|
||||
agentMessageViewMode: 'text',
|
||||
developerMode: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ const initialState: ThemeState = {
|
||||
mode: 'system',
|
||||
tabBarLabels: 'hover',
|
||||
fontSize: 'medium',
|
||||
agentMessageViewMode: 'bubbles',
|
||||
agentMessageViewMode: 'text',
|
||||
developerMode: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
import { combineReducers, configureStore } from '@reduxjs/toolkit';
|
||||
import { render, type RenderOptions } from '@testing-library/react';
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import { SidebarSlotOutlet, SidebarSlotProvider } from '../components/layout/shell/SidebarSlot';
|
||||
import { getCoreStateSnapshot } from '../lib/coreState/store';
|
||||
import { CoreStateContext } from '../providers/coreStateContext';
|
||||
import backendMeetReducer from '../store/backendMeetSlice';
|
||||
@@ -93,7 +95,17 @@ export function renderWithProviders(
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<CoreStateContext.Provider value={coreStateStub}>
|
||||
<MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
{/* Provide the root sidebar slot so pages that project their nav via
|
||||
SidebarContent (Settings/Brain/Connections/Chat) render it. The
|
||||
outlet is portaled to document.body so it doesn't become the
|
||||
render container's firstChild (which would break tests asserting
|
||||
a null/empty render); `screen` queries still find projected content. */}
|
||||
<SidebarSlotProvider>
|
||||
{createPortal(<SidebarSlotOutlet />, document.body)}
|
||||
{children}
|
||||
</SidebarSlotProvider>
|
||||
</MemoryRouter>
|
||||
</CoreStateContext.Provider>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user