Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions src/web-ui/src/app/components/NavPanel/MainNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*
Expand All @@ -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,
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -245,6 +244,10 @@ const MainNav: React.FC<MainNavProps> = ({
};
}, [workspaceMenuOpen, updateWorkspaceMenuPos]);

const handleCreateSession = useCallback(() => {
void activateProductAction('session.new');
}, []);

const handleOpenAgents = useCallback(() => {
void activateProductAction('surface.agents.open');
}, []);
Expand Down Expand Up @@ -360,6 +363,7 @@ const MainNav: React.FC<MainNavProps> = ({
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');
Expand Down Expand Up @@ -410,7 +414,20 @@ const MainNav: React.FC<MainNavProps> = ({
</button>
</Tooltip>
</div>
<RealtimeVoiceCallButton />
<Tooltip content={createSessionLabel} placement="right" followCursor>
<button
type="button"
className="bitfun-nav-panel__utility-action"
data-bf-component="nav-panel"
data-bf-part="topAction"
data-bf-action="new-session"
onClick={handleCreateSession}
aria-label={createSessionLabel}
data-testid="nav-new-session-btn"
>
<Plus size={15} aria-hidden="true" />
</button>
</Tooltip>
</div>
</div>
)}
Expand Down
33 changes: 33 additions & 0 deletions src/web-ui/src/app/components/NavPanel/NavPanel.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ──────────────────────────────────────────────
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string> = 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<typeof vi.fn>;
let openAppearanceSettings: ReturnType<typeof vi.fn>;

const Harness = () => {
const [open, setOpen] = useState(false);
return (
<AppearanceQuickSwitchMenuItem
open={open}
onOpenChange={setOpen}
onCloseParentMenu={closeParentMenu}
onOpenAppearanceSettings={openAppearanceSettings}
/>
);
};

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(<Harness />));
});

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<HTMLButtonElement>(
'[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<HTMLButtonElement>(
'[data-testid="nav-settings-appearance-item"]',
);
act(() => trigger!.click());
const ocean = document.querySelector<HTMLButtonElement>(
'[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<HTMLButtonElement>(
'[data-testid="nav-settings-appearance-item"]',
);
act(() => trigger!.click());
const configure = document.querySelector<HTMLButtonElement>(
'[data-testid="nav-settings-appearance-settings"]',
);
act(() => configure!.click());

expect(openAppearanceSettings).toHaveBeenCalledOnce();
});
});
Loading
Loading