From 730a8650b3351f795870b776daf15e8f20400690 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:44:49 -0700 Subject: [PATCH] =?UTF-8?q?feat(shell):=20root=20two-panel=20layout=20?= =?UTF-8?q?=E2=80=94=20unified=20sidebar=20+=20chat-as-home=20(#3751)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/App.tsx | 91 ++-- app/src/AppRoutes.tsx | 12 +- app/src/__tests__/App.boot.test.tsx | 2 +- app/src/components/BottomTabBar.tsx | 359 -------------- app/src/components/ConnectionIndicator.tsx | 19 +- app/src/components/DefaultRedirect.tsx | 4 +- app/src/components/LanguageSelect.tsx | 8 +- .../__tests__/BottomTabBar.test.tsx | 367 -------------- .../__tests__/ConnectionIndicator.test.tsx | 18 +- .../__tests__/DefaultRedirect.test.tsx | 5 +- app/src/components/accounts/WebviewHost.tsx | 15 +- app/src/components/chat/ChatNewWindowHero.tsx | 185 +++++++ .../components/chat/ComposerTokenStats.tsx | 20 +- app/src/components/home/HomeBanners.tsx | 63 +-- .../home/__tests__/HomeBanners.test.tsx | 2 +- .../intelligence/memoryGraphLayout.ts | 18 +- .../components/layout/shell/AppSidebar.tsx | 51 ++ .../layout/shell/RootShellLayout.tsx | 205 ++++++++ .../components/layout/shell/SidebarHeader.tsx | 150 ++++++ .../components/layout/shell/SidebarNav.tsx | 89 ++++ .../components/layout/shell/SidebarSlot.tsx | 65 +++ app/src/components/layout/shell/navIcons.tsx | 107 +++++ .../settings/layout/SettingsLayout.tsx | 60 +-- .../settings/layout/SettingsSidebar.tsx | 6 - app/src/config/__tests__/navConfig.test.ts | 31 +- app/src/config/navConfig.ts | 21 +- app/src/features/human/HumanPage.tsx | 13 +- app/src/lib/i18n/ar.ts | 10 +- app/src/lib/i18n/bn.ts | 10 +- app/src/lib/i18n/de.ts | 9 +- app/src/lib/i18n/en.ts | 20 +- app/src/lib/i18n/es.ts | 10 +- app/src/lib/i18n/fr.ts | 9 +- app/src/lib/i18n/hi.ts | 10 +- app/src/lib/i18n/id.ts | 10 +- app/src/lib/i18n/it.ts | 9 +- app/src/lib/i18n/ko.ts | 10 +- app/src/lib/i18n/pl.ts | 10 +- app/src/lib/i18n/pt.ts | 9 +- app/src/lib/i18n/ru.ts | 9 +- app/src/lib/i18n/zh-CN.ts | 9 +- app/src/pages/Accounts.tsx | 194 ++++---- app/src/pages/Brain.tsx | 58 ++- app/src/pages/Conversations.tsx | 453 ++++++++---------- app/src/pages/Home.tsx | 16 +- app/src/pages/Skills.tsx | 55 +-- .../Conversations.attachments.test.tsx | 20 +- .../__tests__/Conversations.render.test.tsx | 177 +------ app/src/store/themeSlice.test.ts | 6 +- app/src/store/themeSlice.ts | 2 +- app/src/test/test-utils.tsx | 14 +- 51 files changed, 1562 insertions(+), 1563 deletions(-) delete mode 100644 app/src/components/BottomTabBar.tsx delete mode 100644 app/src/components/__tests__/BottomTabBar.test.tsx create mode 100644 app/src/components/chat/ChatNewWindowHero.tsx create mode 100644 app/src/components/layout/shell/AppSidebar.tsx create mode 100644 app/src/components/layout/shell/RootShellLayout.tsx create mode 100644 app/src/components/layout/shell/SidebarHeader.tsx create mode 100644 app/src/components/layout/shell/SidebarNav.tsx create mode 100644 app/src/components/layout/shell/SidebarSlot.tsx create mode 100644 app/src/components/layout/shell/navIcons.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index f4b42bad9..6bf335a54 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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 ( -
- -
-
- - -
- {!onOnboardingRoute && } -
- - {/* 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. */} - - {/* 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 && ( - - )} + // 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 = ( +
+ +
); + + return ( + +
+ +
+ {chromeless ? ( + content + ) : ( + }>{content} + )} +
+ + {/* 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. */} + + {/* 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 && ( + + )} +
+
+ ); } export default App; diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index 8b11944f8..0f8fb1761 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -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 */} - - - - } - /> + {/* Home is merged into the unified chat surface — /home redirects to /chat + (the chat's empty "new window" state is the former Home greeting). */} + } /> {/* Human — first-class destination again (restored after the IA Phase 6 merge into Assistant). Renders the Human/mascot surface. iOS serves diff --git a/app/src/__tests__/App.boot.test.tsx b/app/src/__tests__/App.boot.test.tsx index aa7587370..181585202 100644 --- a/app/src/__tests__/App.boot.test.tsx +++ b/app/src/__tests__/App.boot.test.tsx @@ -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', () => ({ diff --git a/app/src/components/BottomTabBar.tsx b/app/src/components/BottomTabBar.tsx deleted file mode 100644 index 6976e6d43..000000000 --- a/app/src/components/BottomTabBar.tsx +++ /dev/null @@ -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 ( - - - - ); - case 'human': - return ( - - - - ); - case 'chat': - return ( - - - - ); - case 'connections': - return ( - - - - ); - case 'activity': - // Reuse the Intelligence/memory lightbulb icon for the Activity tab - return ( - - - - ); - case 'settings': - return ( - - - - - ); - case 'brain': - // Two symmetric lobes — reads clearly as a brain. - return ( - - - - - ); - 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(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 ( - - ); - }; - - // 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 ); }; diff --git a/app/src/config/__tests__/navConfig.test.ts b/app/src/config/__tests__/navConfig.test.ts index 910f9a0d1..8ef6ae773 100644 --- a/app/src/config/__tests__/navConfig.test.ts +++ b/app/src/config/__tests__/navConfig.test.ts @@ -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(); }); diff --git a/app/src/config/navConfig.ts b/app/src/config/navConfig.ts index 4945776ba..a3b6cb3f6 100644 --- a/app/src/config/navConfig.ts +++ b/app/src/config/navConfig.ts @@ -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 ───────────────────────────────────────────────────── diff --git a/app/src/features/human/HumanPage.tsx b/app/src/features/human/HumanPage.tsx index bab8bd28e..23241c0b8 100644 --- a/app/src/features/human/HumanPage.tsx +++ b/app/src/features/human/HumanPage.tsx @@ -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. */}
{customMascotGifUrl ? ( @@ -76,10 +76,13 @@ const HumanPage = () => { {t('voice.pushToTalk')} - {/* Chat sidebar — vertically centered above the BottomTabBar (~80px). */} -
-
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index c3dbd1586..2a01ba146 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': 'نسخ معرف الجزء', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 3e1201ed5..c591cffc7 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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 কপি করুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index b24becc72..54679c415 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 388e7843b..ebf90ccc2 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 1952dab38..f62a4013f 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 890558f64..c71f4a2cf 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 989837f2e..8a76c26bd 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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 कॉपी करें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index e468bb4d1..0feda74b3 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index b8e0379ec..40a8003f9 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 1f926b337..d6c9fc032 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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 복사', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 75f61ae77..9ce792392 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 45e1e8242..af46fdb84 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 916e0bcb3..7b4664850 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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 блока', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index db0c7075d..b87b29d2a 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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', diff --git a/app/src/pages/Accounts.tsx b/app/src/pages/Accounts.tsx index 73244d067..87315f3a9 100644 --- a/app/src/pages/Accounts.tsx +++ b/app/src/pages/Accounts.tsx @@ -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} ) : 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). */} - - {tooltip} - + {/* 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. */} ); @@ -336,102 +330,108 @@ const Accounts = () => { return (
- {/* Narrow icon rail — always rendered. */} - + {accounts.map(acct => ( + selectAccount(acct.id)} + onContextMenu={e => openContextMenu(acct.id, e)} + tooltip={acct.label} + analyticsId={`chat-app-rail-account-${acct.provider}`} + badge={unreadByAccount[acct.id]}> + + + ))} + + +
+ {/* "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. */} -
- {isAgentSelected ? ( - <> - {/* Agent chat — face mode uses sidebar variant to avoid a second - thread list; normal mode uses the full-page variant (AgentChatPanel). */} -
- {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. -
- -
- ) : ( - // `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. -
- -
- )} + {/* 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 ? ( +
+
+
+
- {/* Mascot + TTS panel — only visible in face mode */} - {faceMode && } - - ) : active ? ( -
-
- ) : ( -
- {t('accounts.noAccounts')} + +
+ ) : ( +
+ {/* 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. */} +
+
- )} -
+ + {/* Selected connected app — fills the main content fully (no padding + or margins) on top of the hidden agent chat. */} + {!isAgentSelected && active && ( +
+ +
+ )} + + {!isAgentSelected && !active && ( +
+ {t('accounts.noAccounts')} +
+ )} +
+ )} - +
-

- {t('nav.brain')} -

-

- {t('brain.subtitle')} -

-
+

+ {t('brain.subtitle')} +

} /> - }> +
+ +
{/* 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) ? ( - - {/* Distinct tab query key so the embedded Intelligence panel's - internal tab switches don't overwrite Brain's own - `?tab=intelligence` and unmount it. */} - {activeTab === 'intelligence' && } - {activeTab === 'memory-data' && } - {activeTab === 'memory-debug' && } - {activeTab === 'analysis-views' && } - + // 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). +
+
+ + {/* Distinct tab query key so the embedded Intelligence panel's + internal tab switches don't overwrite Brain's own + `?tab=intelligence` and unmount it. */} + {activeTab === 'intelligence' && } + {activeTab === 'memory-data' && } + {activeTab === 'memory-debug' && } + {activeTab === 'analysis-views' && } + +
+
) : ( // Bespoke tabs share the standard scaffold: a single scrolling body, // all custom controls live inside it. @@ -316,7 +312,7 @@ export default function Brain() {
)} - +
diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 8bb225668..dbcc2d9f1 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -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(null); - const [editingTitle, setEditingTitle] = useState(false); - const [editTitleValue, setEditTitleValue] = useState(''); - const editTitleInputRef = useRef(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" /> + {/* New conversation — a subtle, centered thread-style row (not a loud + button), below the pills and above the thread list. */} +
{visibleThreads.length === 0 ? (

@@ -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 && ( -

- -
- {selectedThreadParent ? ( - - ) : null} - {editingTitle ? ( - 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 - /> - ) : ( -
-

- {resolveThreadDisplayTitle(selectedThreadId)} -

- {selectedThreadId && ( - - )} -
- )} - {resolvedModel && ( - - {resolvedModel} - - )} -
- <> -
- - -
- {(selectedThreadId ?? firstActiveThreadId) && ( - - )} - {/* 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 && ( - - )} - - -
- )}
+ // 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 ? ( -
+
{Array.from({ length: 4 }).map((_, i) => (
) : hasVisibleMessages || hasTaskBoard ? ( -
+
{selectedTaskBoard && hasTaskBoard && (
+ ) : isNewWindow ? ( + ) : (

{t('chat.noMessages')}

@@ -2334,7 +2161,26 @@ const Conversations = ({ )}
-
+ {/* Full-width fade so messages dissolve into the background (black/white + per theme) behind the floating composer. Page variant only. */} + {!isSidebar && ( +
); @@ -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. - + <> + {projectThreadList && ( + +
+ {threadSidebar} +
+
+ )} {mainPanel} -
+ + ) : ( + // 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. + <> + +
+ {threadSidebar} +
+
+
{mainPanel}
+ )} { return (
+ {/* Welcome title */} +

+ {typedWelcome} + +

+
{shouldShowBudgetCompletedMessage && ( {
- {/* Welcome title */} -

- {typedWelcome} - -

- {/* Connection status */}
diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx index c2796efa7..22098915b 100644 --- a/app/src/pages/Skills.tsx +++ b/app/src/pages/Skills.tsx @@ -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 (
- +
handleTabChange(value as ConnectionsTab)} - header={ -

- {t('nav.connections')} -

- } groups={[ { label: t('connections.groups.integrations'), @@ -912,23 +899,30 @@ export default function Skills() { }, ]} /> - }> +
+ +
{/* Intelligence panels relocated from Settings are themselves PanelPage panels (description, no title; the back button hides because the Connections sidebar owns navigation), so they fill the content pane and own their scroll directly. */} {INTELLIGENCE_TABS.has(activeTab) ? ( - - {activeTab === 'llm' && } - {activeTab === 'voice' && } - {activeTab === 'embeddings' && } - {activeTab === 'search' && } - {activeTab === 'composio-key' && } - + // 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). +
+
+ + {activeTab === 'llm' && } + {activeTab === 'voice' && } + {activeTab === 'embeddings' && } + {activeTab === 'search' && } + {activeTab === 'composio-key' && } + +
+
) : ( - +
{/*
@@ -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"> +

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

{showLocalComposioApiKeyBanner && ( handleTabChange('composio-key')} @@ -1134,7 +1131,7 @@ export default function Skills() {
)} - +
{channelModalDef && ( setChannelModalDef(null)} /> diff --git a/app/src/pages/__tests__/Conversations.attachments.test.tsx b/app/src/pages/__tests__/Conversations.attachments.test.tsx index 9b029470b..5c5db05bb 100644 --- a/app/src/pages/__tests__/Conversations.attachments.test.tsx +++ b/app/src/pages/__tests__/Conversations.attachments.test.tsx @@ -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()), CHAT_ATTACHMENTS_ENABLED: true, @@ -247,7 +252,10 @@ async function renderWithSelectedThread() { render( - + + + + ); @@ -564,7 +572,10 @@ describe('Conversations — attachment feature', () => { render( - + + + + ); @@ -613,7 +624,10 @@ describe('Conversations — attachment feature', () => { render( - + + + + ); diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index 3784afb35..307445971 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -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 } }) => state.socket?.byUser?.__pending__?.status ?? 'disconnected', @@ -202,7 +208,12 @@ async function renderConversations(preload: Record = {}) { render( - + {/* The thread sidebar is projected into the root sidebar slot, so the + page needs a provider + outlet for that portal to mount in tests. */} + + + + ); @@ -210,12 +221,9 @@ async function renderConversations(preload: Record = {}) { 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 | 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).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(); diff --git a/app/src/store/themeSlice.test.ts b/app/src/store/themeSlice.test.ts index 20e869d33..1965423f0 100644 --- a/app/src/store/themeSlice.test.ts +++ b/app/src/store/themeSlice.test.ts @@ -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, }); }); diff --git a/app/src/store/themeSlice.ts b/app/src/store/themeSlice.ts index 847682552..3e1455025 100644 --- a/app/src/store/themeSlice.ts +++ b/app/src/store/themeSlice.ts @@ -42,7 +42,7 @@ const initialState: ThemeState = { mode: 'system', tabBarLabels: 'hover', fontSize: 'medium', - agentMessageViewMode: 'bubbles', + agentMessageViewMode: 'text', developerMode: false, }; diff --git a/app/src/test/test-utils.tsx b/app/src/test/test-utils.tsx index 2de5886b0..7e2a7a0d6 100644 --- a/app/src/test/test-utils.tsx +++ b/app/src/test/test-utils.tsx @@ -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 ( - {children} + + {/* 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. */} + + {createPortal(, document.body)} + {children} + + );