diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index 7cee4b4605..457f4e550a 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -2,7 +2,7 @@ * MainNav — primary product navigation sidebar. * * Layout (top to bottom): - * 1. Search and client voice assistant + * 1. Search and New Session * 2. AI Assistant, Task Board, Mini Apps, then Extensions & Compatibility * 3. Unified Sessions (all or grouped by project / assistant) * @@ -15,7 +15,7 @@ import { createPortal } from 'react-dom'; import { Icon, KeyHint, Menu, MenuItem, MenuSection, MenuSeparator, NavigationPanel, ScrollArea, Tooltip } from '@bitfun/ui'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime'; -import { FolderOpen, FolderPlus, History, Users, Network, CalendarClock } from 'lucide-react'; +import { Plus, FolderOpen, FolderPlus, History, Users, Network, CalendarClock } from 'lucide-react'; // import { PanelsTopLeft } from 'lucide-react'; // temporarily hidden: Pages nav entry import { BITFUN_ICON_SIZE, @@ -48,7 +48,6 @@ import { subscribeGlobalSearchShortcut, } from '@/app/global-search/globalSearchShortcut'; import { useExternalAppAwareness } from '@/infrastructure/config/components/external-sources/useExternalAppAwareness'; -import { RealtimeVoiceCallButton } from '@/flow_chat/components/voice/RealtimeVoiceCallButton'; import './NavPanel.scss'; @@ -245,6 +244,10 @@ const MainNav: React.FC = ({ }; }, [workspaceMenuOpen, updateWorkspaceMenuPos]); + const handleCreateSession = useCallback(() => { + void activateProductAction('session.new'); + }, []); + const handleOpenAgents = useCallback(() => { void activateProductAction('surface.agents.open'); }, []); @@ -360,6 +363,7 @@ const MainNav: React.FC = ({ getAppearanceOverlayHost() ) : null; + const createSessionLabel = t('nav.sessions.newSession'); const addSessionGroupTooltip = t('nav.tooltips.addSessionGroup'); const agentsTooltip = t('nav.tooltips.agents'); const skillsTooltip = t('nav.tooltips.skills'); @@ -410,7 +414,20 @@ const MainNav: React.FC = ({ - + + + )} diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 56e3f785eb..09ee3406df 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -1897,6 +1897,37 @@ $_section-header-height: 22px; } } +.bitfun-nav-panel__appearance-menu-trigger { + &.is-open { + background: var(--bf-color-action-neutral-surface); + } +} + +.bitfun-nav-panel__appearance-menu-current { + display: block; + max-width: 82px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Positioning and open-state composition only; the design-system Menu owns chrome. */ +.bitfun-nav-panel__appearance-submenu { + position: fixed; + max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + z-index: 10000; + animation: bitfun-nav-popover-in 150ms cubic-bezier(0.23, 1, 0.32, 1) both; + + &[data-placement='right'] { + transform-origin: top left; + } + + &[data-placement='left'] { + transform-origin: top right; + } +} + // ────────────────────────────────────────────── // Device overview — one compact model, local and connected states // ────────────────────────────────────────────── @@ -3153,6 +3184,7 @@ $_section-header-height: 22px; .bitfun-nav-panel__workspace-menu, .bitfun-nav-panel__mode-dropdown, .bitfun-nav-panel__footer-menu, +.bitfun-nav-panel__appearance-submenu, .bitfun-nav-panel__footer-multimodal-menu { animation: bitfun-nav-popover-in 150ms cubic-bezier(0.23, 1, 0.32, 1) both; @@ -3243,6 +3275,7 @@ $_section-header-height: 22px; .bitfun-nav-panel__workspace-menu, .bitfun-nav-panel__mode-dropdown, .bitfun-nav-panel__footer-menu, + .bitfun-nav-panel__appearance-submenu, .bitfun-nav-panel__footer-multimodal-menu, .bitfun-device-overview { animation: none; diff --git a/src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.test.tsx b/src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.test.tsx new file mode 100644 index 0000000000..95f0c59698 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.test.tsx @@ -0,0 +1,165 @@ +// @vitest-environment jsdom + +import React, { act, useState } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const selectAppearance = vi.hoisted(() => vi.fn()); +const notifyError = vi.hoisted(() => vi.fn()); + +vi.mock('@/infrastructure/appearance', () => ({ + SYSTEM_APPEARANCE_ID: 'system', + useAppearance: () => ({ + appearances: [ + { + id: 'bitfun-light', + name: 'BitFun Light', + version: '1.0.0', + mode: 'light', + source: 'builtin', + }, + { + id: 'custom-ocean', + name: 'Ocean', + version: '1.0.0', + mode: 'dark', + source: 'imported', + }, + ], + current: { id: 'bitfun-light', name: 'BitFun Light' }, + initialized: true, + select: selectAppearance, + selectedAppearanceId: 'bitfun-light', + status: 'ready', + }), +})); + +vi.mock('@/infrastructure/i18n/hooks/useI18n', () => ({ + useI18n: (namespace: string) => ({ + t: (key: string, options?: { defaultValue?: string }) => { + const translations: Record = namespace === 'settings/application' + ? { + 'appearance.systemAppearance': 'Match system', + 'appearance.presets.bitfun-light.name': 'Light', + } + : { + 'nav.settingsMenu.theme': 'Theme', + 'nav.settingsMenu.themeConfiguration': 'Theme configuration', + 'nav.settingsMenu.appearanceSwitchFailed': 'Could not switch the theme. Try again.', + }; + return translations[key] ?? options?.defaultValue ?? key; + }, + }), +})); + +vi.mock('@/infrastructure/appearance/runtime/AppearanceOverlayHost', () => ({ + getAppearanceOverlayHost: () => document.body, +})); + +vi.mock('@/shared/notification-system', () => ({ + useNotification: () => ({ error: notifyError }), +})); + +import AppearanceQuickSwitchMenuItem from './AppearanceQuickSwitchMenuItem'; + +describe('AppearanceQuickSwitchMenuItem', () => { + let container: HTMLDivElement; + let root: Root; + let closeParentMenu: ReturnType; + let openAppearanceSettings: ReturnType; + + const Harness = () => { + const [open, setOpen] = useState(false); + return ( + + ); + }; + + beforeEach(() => { + selectAppearance.mockReset().mockResolvedValue(undefined); + notifyError.mockReset(); + closeParentMenu = vi.fn(); + openAppearanceSettings = vi.fn(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => root.render()); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it('shows the current theme and opens the checked quick-switch submenu only on click', () => { + const trigger = container.querySelector( + '[data-testid="nav-settings-appearance-item"]', + ); + expect(trigger).not.toBeNull(); + expect(trigger?.textContent).toContain('Theme'); + expect(trigger?.textContent).toContain('Light'); + expect(trigger?.getAttribute('aria-haspopup')).toBe('menu'); + + act(() => { + trigger!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + }); + expect(document.querySelector('[data-testid="nav-settings-appearance-menu"]')).toBeNull(); + + act(() => trigger!.click()); + + const submenu = document.querySelector('[data-testid="nav-settings-appearance-menu"]'); + const selected = document.querySelector( + '[data-testid="nav-settings-appearance-option"][data-appearance-id="bitfun-light"]', + ); + expect(submenu).not.toBeNull(); + expect(selected?.getAttribute('role')).toBe('menuitemradio'); + expect(selected?.getAttribute('aria-checked')).toBe('true'); + expect(submenu?.textContent).toContain('Match system'); + expect(submenu?.textContent).toContain('Ocean'); + + act(() => trigger!.click()); + expect(document.querySelector('[data-testid="nav-settings-appearance-menu"]')).toBeNull(); + }); + + it('applies a selected theme immediately and closes the parent menu', async () => { + const trigger = container.querySelector( + '[data-testid="nav-settings-appearance-item"]', + ); + act(() => trigger!.click()); + const ocean = document.querySelector( + '[data-testid="nav-settings-appearance-option"][data-appearance-id="custom-ocean"]', + ); + + await act(async () => { + ocean!.click(); + await Promise.resolve(); + }); + + expect(selectAppearance).toHaveBeenCalledWith('custom-ocean'); + expect(closeParentMenu).toHaveBeenCalledOnce(); + }); + + it('keeps full theme configuration available from the submenu', () => { + const trigger = container.querySelector( + '[data-testid="nav-settings-appearance-item"]', + ); + act(() => trigger!.click()); + const configure = document.querySelector( + '[data-testid="nav-settings-appearance-settings"]', + ); + act(() => configure!.click()); + + expect(openAppearanceSettings).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.tsx b/src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.tsx new file mode 100644 index 0000000000..0bfa54d18b --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.tsx @@ -0,0 +1,301 @@ +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type RefObject, +} from 'react'; +import { createPortal } from 'react-dom'; +import { Icon, Menu, MenuItem, MenuSeparator } from '@bitfun/ui'; + +import { + SYSTEM_APPEARANCE_ID, + useAppearance, + type AppearanceCatalogEntry, + type AppearanceSelectionId, +} from '@/infrastructure/appearance'; +import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; +import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; +import { useNotification } from '@/shared/notification-system'; + +interface AppearanceQuickSwitchMenuItemProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onCloseParentMenu: () => void; + onOpenAppearanceSettings: () => void; +} + +interface SubmenuLayout { + left: number; + placement: 'left' | 'right'; + top: number; +} + +const SUBMENU_GAP = 5; +const SUBMENU_FALLBACK_WIDTH = 228; +const SUBMENU_FALLBACK_HEIGHT = 360; +const VIEWPORT_PADDING = 8; + +const clamp = (value: number, min: number, max: number): number => ( + Math.min(Math.max(value, min), Math.max(min, max)) +); + +function useSubmenuLayout( + open: boolean, + anchorRef: RefObject, + submenuRef: RefObject, + layoutRevision: unknown, +): SubmenuLayout | null { + const [layout, setLayout] = useState(null); + + const updateLayout = useCallback(() => { + const anchor = anchorRef.current; + const submenu = submenuRef.current; + if (!anchor || !submenu) return; + + const anchorRect = anchor.getBoundingClientRect(); + const submenuRect = submenu.getBoundingClientRect(); + const submenuWidth = submenuRect.width + || submenu.offsetWidth + || submenu.scrollWidth + || SUBMENU_FALLBACK_WIDTH; + const submenuHeight = submenuRect.height + || submenu.offsetHeight + || submenu.scrollHeight + || SUBMENU_FALLBACK_HEIGHT; + const preferredLeft = anchorRect.right + SUBMENU_GAP; + const opensRight = preferredLeft + submenuWidth <= window.innerWidth - VIEWPORT_PADDING; + const nextLayout: SubmenuLayout = { + left: clamp( + opensRight ? preferredLeft : anchorRect.left - SUBMENU_GAP - submenuWidth, + VIEWPORT_PADDING, + window.innerWidth - submenuWidth - VIEWPORT_PADDING, + ), + placement: opensRight ? 'right' : 'left', + top: clamp( + anchorRect.top - 4, + VIEWPORT_PADDING, + window.innerHeight - submenuHeight - VIEWPORT_PADDING, + ), + }; + + setLayout(current => current?.left === nextLayout.left + && current.top === nextLayout.top + && current.placement === nextLayout.placement + ? current + : nextLayout); + }, [anchorRef, submenuRef]); + + useLayoutEffect(() => { + if (!open) { + setLayout(null); + return; + } + + updateLayout(); + window.addEventListener('resize', updateLayout); + window.addEventListener('scroll', updateLayout, true); + const resizeObserver = typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(updateLayout); + if (anchorRef.current) resizeObserver?.observe(anchorRef.current); + if (submenuRef.current) resizeObserver?.observe(submenuRef.current); + + return () => { + window.removeEventListener('resize', updateLayout); + window.removeEventListener('scroll', updateLayout, true); + resizeObserver?.disconnect(); + }; + }, [anchorRef, open, submenuRef, updateLayout]); + + useLayoutEffect(() => { + if (open) updateLayout(); + }, [layoutRevision, open, updateLayout]); + + return layout; +} + +const AppearanceQuickSwitchMenuItem: React.FC = ({ + open, + onOpenChange, + onCloseParentMenu, + onOpenAppearanceSettings, +}) => { + const { t } = useI18n('common'); + const { t: tApplication } = useI18n('settings/application'); + const { error: notifyError } = useNotification(); + const { + appearances, + current, + initialized, + select, + selectedAppearanceId, + status, + } = useAppearance(); + const triggerRef = useRef(null); + const submenuRef = useRef(null); + const focusSubmenuOnOpenRef = useRef(false); + + const getAppearanceDisplayName = useCallback((appearance: AppearanceCatalogEntry) => { + if (appearance.source !== 'builtin') return appearance.name; + const presetId = appearance.id.replace(/^builtin\./, ''); + return tApplication(`appearance.presets.${presetId}.name`, { + defaultValue: appearance.name, + }); + }, [tApplication]); + + const selectedDisplayName = useMemo(() => { + if (selectedAppearanceId === SYSTEM_APPEARANCE_ID) { + return tApplication('appearance.systemAppearance'); + } + const selected = appearances.find(appearance => appearance.id === selectedAppearanceId); + if (selected) return getAppearanceDisplayName(selected); + return current?.name ?? t('nav.settingsMenu.theme'); + }, [appearances, current?.name, getAppearanceDisplayName, selectedAppearanceId, t, tApplication]); + + const submenuLayout = useSubmenuLayout( + open, + triggerRef, + submenuRef, + `${appearances.length}:${selectedAppearanceId}`, + ); + + const focusFirstSubmenuItem = useCallback(() => { + const firstItem = submenuRef.current?.querySelector( + '[data-bf-menu-item]:not(:disabled)', + ); + firstItem?.focus(); + }, []); + + useEffect(() => { + if (!open || !focusSubmenuOnOpenRef.current) return; + focusSubmenuOnOpenRef.current = false; + const frame = window.requestAnimationFrame(focusFirstSubmenuItem); + return () => window.cancelAnimationFrame(frame); + }, [focusFirstSubmenuItem, open]); + + const openFromKeyboard = useCallback(() => { + focusSubmenuOnOpenRef.current = true; + if (open) { + window.requestAnimationFrame(focusFirstSubmenuItem); + } else { + onOpenChange(true); + } + }, [focusFirstSubmenuItem, onOpenChange, open]); + + const handleTriggerKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowRight') { + event.preventDefault(); + event.stopPropagation(); + openFromKeyboard(); + } + }; + + const handleSubmenuKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'ArrowLeft' && event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + onOpenChange(false); + triggerRef.current?.focus(); + }; + + const handleSelect = useCallback(async (id: AppearanceSelectionId) => { + try { + await select(id); + onCloseParentMenu(); + } catch { + notifyError(t('nav.settingsMenu.appearanceSwitchFailed'), { duration: 4000 }); + } + }, [notifyError, onCloseParentMenu, select, t]); + + const selectionDisabled = !initialized || status === 'applying'; + + return ( + <> + + + {open && createPortal( + + + + {appearances.map(appearance => { + const selected = appearance.id === selectedAppearanceId; + return ( + + ); + })} + + + , + getAppearanceOverlayHost(), + )} + + ); +}; + +export default AppearanceQuickSwitchMenuItem; diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx index ef222d287d..f07f5bd946 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -21,6 +21,7 @@ import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/Ap import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { useSettingsStore } from '@/app/scenes/settings/settingsStore'; import DeviceStatusControl from './DeviceStatusControl'; +import AppearanceQuickSwitchMenuItem from './AppearanceQuickSwitchMenuItem'; const RemoteConnectDialog = lazy(() => import('../../RemoteConnectDialog')); const AboutDialog = lazy(() => @@ -49,6 +50,7 @@ const PersistentFooterActions: React.FC = () => { const [menuOpen, setMenuOpen] = useState(false); const [menuClosing, setMenuClosing] = useState(false); + const [appearanceSubmenuOpen, setAppearanceSubmenuOpen] = useState(false); const [deviceOverviewOpen, setDeviceOverviewOpen] = useState(false); const menuTriggerRef = useRef(null); const menuPopoverRef = useRef(null); @@ -84,6 +86,7 @@ const PersistentFooterActions: React.FC = () => { }, []); const closeMenu = useCallback(() => { + setAppearanceSubmenuOpen(false); setMenuClosing(true); setTimeout(() => { setMenuOpen(false); @@ -96,6 +99,7 @@ const PersistentFooterActions: React.FC = () => { closeMenu(); } else { setDeviceOverviewOpen(false); + setAppearanceSubmenuOpen(false); setMenuOpen(true); } }; @@ -112,7 +116,7 @@ const PersistentFooterActions: React.FC = () => { void activateProductAction('settings.open'); }, [closeMenu]); - const handleOpenThemeConfiguration = useCallback(() => { + const handleOpenAppearanceSettings = useCallback(() => { closeMenu(); useSettingsStore.getState().openPage('application.appearance'); void activateProductAction('settings.open'); @@ -212,6 +216,16 @@ const PersistentFooterActions: React.FC = () => { className={`bitfun-nav-panel__footer-menu${menuClosing ? ' is-closing' : ''}`} aria-label={t('shared:features.settings')} data-testid="nav-settings-menu" + onKeyDown={(event) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + if (appearanceSubmenuOpen) { + setAppearanceSubmenuOpen(false); + } else { + closeMenu(); + menuTriggerRef.current?.focus(); + } + }} style={{ top: `${menuLayout?.top ?? 0}px`, left: `${menuLayout?.left ?? 0}px`, @@ -226,13 +240,12 @@ const PersistentFooterActions: React.FC = () => { {t('nav.settingsMenu.floatingWindow')} - + - {acpClients.map(client => { - const label = client.name || client.id; - return ( - } - onClick={() => { void handleCreateAcpSession(client); }} - data-testid="nav-workspace-menu-create-acp-session" - data-acp-client-id={client.id} - > - {t('nav.sessions.newExternalAgentSessionShort', { agentName: label })} - - ); - })} - {acpClientsLoading ? ( - } disabled> - {t('app.loading')} - - ) : null} + { void handleCreateAcpSession(client); }} + /> } onClick={() => { void handleCreateInitSession(); }} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss index f8a6f915cf..0c975e56fa 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss @@ -970,6 +970,20 @@ animation: bitfun-footer-menu-in $motion-fast $easing-decelerate forwards; } + &__workspace-acp-menu-trigger.is-open { + background: var(--bf-color-action-neutral-surface); + } + + &__workspace-acp-submenu { + &[data-placement='right'] { + transform-origin: top left; + } + + &[data-placement='left'] { + transform-origin: top right; + } + } + &__workspace-item-sessions { position: relative; z-index: 0; @@ -1504,7 +1518,8 @@ } @media (prefers-reduced-motion: reduce) { - .bitfun-nav-panel__workspace-item-menu-popover { + .bitfun-nav-panel__workspace-item-menu-popover, + .bitfun-nav-panel__workspace-acp-submenu { animation: none; } } diff --git a/src/web-ui/src/app/components/NavPanel/unifiedSessionCreation.test.ts b/src/web-ui/src/app/components/NavPanel/unifiedSessionCreation.test.ts index 53f3cadeba..ea0b0b910a 100644 --- a/src/web-ui/src/app/components/NavPanel/unifiedSessionCreation.test.ts +++ b/src/web-ui/src/app/components/NavPanel/unifiedSessionCreation.test.ts @@ -7,20 +7,37 @@ function source(relativePath: string): string { } describe('unified project session creation', () => { - it('places the client voice action beside search instead of the new-session shortcut', () => { + it('keeps New Session beside search and moves realtime voice to the scene corner', () => { const mainNav = source('./MainNav.tsx'); + const workspaceBody = source('../../layout/WorkspaceBody.tsx'); + const voiceLauncher = source('../../../flow_chat/components/voice/RealtimeVoiceCallButton.tsx'); + const voiceLauncherStyles = source('../../../flow_chat/components/voice/RealtimeVoiceCallButton.scss'); const workspaceItem = source('./sections/workspaces/WorkspaceItem.tsx'); const utilityRowIndex = mainNav.indexOf('data-bf-part="utilityRow"'); - const voiceIndex = mainNav.indexOf(''); + const newSessionIndex = mainNav.indexOf('data-testid="nav-new-session-btn"'); const sectionsIndex = mainNav.indexOf('data-testid="nav-sections"'); const sessionsSectionIndex = mainNav.indexOf('data-bf-section="sessions"'); - expect(voiceIndex).toBeGreaterThan(utilityRowIndex); - expect(sectionsIndex).toBeGreaterThan(voiceIndex); - expect(sessionsSectionIndex).toBeGreaterThan(voiceIndex); - expect(mainNav).not.toContain('data-testid="nav-new-session-btn"'); - expect(mainNav).not.toContain('