feat(shell): root two-panel layout — unified sidebar + chat-as-home (#3751)

This commit is contained in:
Steven Enamakel
2026-06-17 13:44:49 -07:00
committed by GitHub
parent 901ae507f5
commit 730a8650b3
51 changed files with 1562 additions and 1563 deletions
+58 -33
View File
@@ -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;
+3 -9
View File
@@ -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
+1 -1
View File
@@ -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', () => ({
-359
View File
@@ -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;
+12 -7
View File
@@ -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>
);
};
+2 -2
View File
@@ -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;
+7 -1
View File
@@ -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();
});
});
+7 -8
View File
@@ -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>
);
}
+17 -3
View File
@@ -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()}
+35 -28
View File
@@ -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 />
&middot;
<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>
);
};
+9 -22
View File
@@ -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();
});
+12 -9
View File
@@ -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 ─────────────────────────────────────────────────────
+8 -5
View File
@@ -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>
+6 -4
View File
@@ -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': 'نسخ معرف الجزء',
+6 -4
View File
@@ -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 কপি করুন',
+6 -3
View File
@@ -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
View File
@@ -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',
+6 -4
View File
@@ -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',
+6 -3
View File
@@ -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",
+6 -4
View File
@@ -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 कॉपी करें',
+6 -4
View File
@@ -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',
+6 -3
View File
@@ -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',
+6 -4
View File
@@ -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 복사',
+6 -4
View File
@@ -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',
+6 -3
View File
@@ -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',
+6 -3
View File
@@ -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 блока',
+6 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+8 -8
View File
@@ -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
View File
@@ -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();
+3 -3
View File
@@ -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,
});
});
+1 -1
View File
@@ -42,7 +42,7 @@ const initialState: ThemeState = {
mode: 'system',
tabBarLabels: 'hover',
fontSize: 'medium',
agentMessageViewMode: 'bubbles',
agentMessageViewMode: 'text',
developerMode: false,
};
+13 -1
View File
@@ -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>
);