mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(nav): make Assistant the center FAB, move Brain into the tab row (#3593)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { AVATAR_MENU_ITEMS, BRAIN_TAB, NAV_TABS } from '../config/navConfig';
|
||||
import { AVATAR_MENU_ITEMS, CENTER_TAB, NAV_TABS } from '../config/navConfig';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../providers/CoreStateProvider';
|
||||
import { trackEvent } from '../services/analytics';
|
||||
@@ -16,11 +16,14 @@ import { resolveUserName } from '../utils/userName';
|
||||
|
||||
// ── SVG icons, keyed by tab id ────────────────────────────────────────────────
|
||||
|
||||
function TabIcon({ id }: { id: string }) {
|
||||
function TabIcon({ id, large = false }: { id: string; large?: boolean }) {
|
||||
// Regular pill tabs render small (w-4); the raised center FAB renders large
|
||||
// (w-6) so its glyph reads as the centerpiece.
|
||||
const cls = large ? 'w-6 h-6' : 'w-4 h-4';
|
||||
switch (id) {
|
||||
case 'home':
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className={cls} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -31,7 +34,7 @@ function TabIcon({ id }: { id: string }) {
|
||||
);
|
||||
case 'human':
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className={cls} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -42,7 +45,7 @@ function TabIcon({ id }: { id: string }) {
|
||||
);
|
||||
case 'chat':
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className={cls} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -53,7 +56,7 @@ function TabIcon({ id }: { id: string }) {
|
||||
);
|
||||
case 'connections':
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className={cls} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -65,7 +68,7 @@ function TabIcon({ id }: { id: string }) {
|
||||
case 'activity':
|
||||
// Reuse the Intelligence/memory lightbulb icon for the Activity tab
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className={cls} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -76,7 +79,7 @@ function TabIcon({ id }: { id: string }) {
|
||||
);
|
||||
case 'settings':
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className={cls} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -95,7 +98,7 @@ function TabIcon({ id }: { id: string }) {
|
||||
// Two symmetric lobes — reads clearly as a brain. Rendered larger and
|
||||
// white inside the raised center circle.
|
||||
return (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className={cls} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -279,40 +282,41 @@ const BottomTabBar = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// The Brain — a raised circular button rising out of the center of the bar.
|
||||
// The bg-colored ring fakes a notch cut into the pill's top edge. `brain-fab`
|
||||
// is targeted by the reduced-motion gate in index.css to silence the glow.
|
||||
// The Assistant — a raised circular button rising out of the center of the
|
||||
// bar. The bg-colored ring fakes a notch cut into the pill's top edge.
|
||||
// `center-fab` marks the button (test/identification hook); it renders a
|
||||
// static glow when active — no pulse.
|
||||
//
|
||||
// `-my-3` collapses the button's 48px (h-12) layout footprint so it no longer
|
||||
// forces the nav row taller than the ~32px pill tabs — the bar height is
|
||||
// driven by the tabs, while `-translate-y-5` still lifts the circle above the
|
||||
// driven by the tabs, while `-translate-y-4` still lifts the circle above the
|
||||
// top edge. Without it the lower half of the raised circle left a dead band
|
||||
// of empty bar height beneath the tabs.
|
||||
const renderBrainButton = () => {
|
||||
const active = isActive(BRAIN_TAB.path);
|
||||
const brainTab = { ...BRAIN_TAB, label: t(BRAIN_TAB.labelKey) };
|
||||
const renderCenterButton = () => {
|
||||
const active = isActive(CENTER_TAB.path);
|
||||
const centerTab = { ...CENTER_TAB, label: t(CENTER_TAB.labelKey) };
|
||||
return (
|
||||
<button
|
||||
key="brain"
|
||||
key={CENTER_TAB.id}
|
||||
type="button"
|
||||
data-walkthrough={BRAIN_TAB.walkthroughAttr}
|
||||
onClick={() => handleTabClick(brainTab, active)}
|
||||
aria-label={brainTab.label}
|
||||
title={brainTab.label}
|
||||
className={`brain-fab group relative mx-1 flex h-12 w-12 -my-3 -translate-y-4 items-center justify-center rounded-full text-white shadow-soft ring-4 ring-stone-200 transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] cursor-pointer dark:ring-neutral-900 ${
|
||||
data-walkthrough={CENTER_TAB.walkthroughAttr}
|
||||
onClick={() => handleTabClick(centerTab, active)}
|
||||
aria-label={centerTab.label}
|
||||
title={centerTab.label}
|
||||
className={`center-fab group relative mx-1 flex h-12 w-12 -my-3 -translate-y-4 items-center justify-center rounded-full text-white shadow-soft ring-4 ring-stone-200 transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] cursor-pointer dark:ring-neutral-900 ${
|
||||
active
|
||||
? 'bg-primary-600 animate-glow-pulse shadow-[0_0_16px_rgba(74,131,221,0.55)] scale-105'
|
||||
? 'bg-primary-600 shadow-[0_0_16px_rgba(74,131,221,0.55)] scale-105'
|
||||
: 'bg-primary-500 hover:bg-primary-600 hover:scale-105'
|
||||
}`}>
|
||||
<TabIcon id="brain" />
|
||||
<TabIcon id={CENTER_TAB.id} large />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Home is a normal pill tab now (no longer pinned/icon-only). The regular
|
||||
// tabs split evenly around the centered Brain button; only the avatar stays
|
||||
// pinned to the far-right behind a divider:
|
||||
// | home · human · assistant ( 🧠 ) connections · activity · settings | [ avatar ]
|
||||
// tabs split evenly around the centered Assistant button; only the avatar
|
||||
// stays pinned to the far-right behind a divider:
|
||||
// | home · human · brain ( 💬 ) connections · activity · settings | [ avatar ]
|
||||
const leftTabs = tabs.slice(0, 3);
|
||||
const rightTabs = tabs.slice(3);
|
||||
|
||||
@@ -341,7 +345,7 @@ const BottomTabBar = () => {
|
||||
}}>
|
||||
<nav className="pointer-events-auto inline-flex items-center gap-1 rounded-sm border border-stone-300 dark:border-neutral-700 bg-stone-200 dark:bg-neutral-900 shadow-soft px-1 py-1">
|
||||
{leftTabs.map(tab => renderTab(tab))}
|
||||
{renderBrainButton()}
|
||||
{renderCenterButton()}
|
||||
{rightTabs.map(tab => renderTab(tab))}
|
||||
<div
|
||||
className="relative ml-1 border-l border-stone-300 pl-1 dark:border-neutral-700"
|
||||
|
||||
@@ -167,13 +167,13 @@ describe('BottomTabBar', () => {
|
||||
agentProfilesApiMock.select.mockResolvedValue(testProfiles);
|
||||
});
|
||||
|
||||
it('renders exactly 6 regular tab buttons (Brain is rendered separately)', async () => {
|
||||
it('renders exactly 6 regular tab buttons (Assistant is rendered separately)', async () => {
|
||||
await renderBottomTabBar('/home');
|
||||
// Query only the regular pill tabs inside <nav>: exclude the avatar button
|
||||
// (aria-haspopup) and the special raised Brain button (tab-brain).
|
||||
// (aria-haspopup) and the special raised Assistant center button (tab-chat).
|
||||
const nav = document.querySelector('nav');
|
||||
const navButtons = nav?.querySelectorAll(
|
||||
'button:not([aria-haspopup]):not([data-walkthrough="tab-brain"])'
|
||||
'button:not([aria-haspopup]):not([data-walkthrough="tab-chat"])'
|
||||
);
|
||||
expect(navButtons).toHaveLength(6);
|
||||
});
|
||||
@@ -188,25 +188,25 @@ describe('BottomTabBar', () => {
|
||||
expect(humanBtn.querySelector('.truncate')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the raised Brain button with data-walkthrough="tab-brain"', async () => {
|
||||
it('renders the raised Assistant center button with data-walkthrough="tab-chat"', async () => {
|
||||
await renderBottomTabBar('/home');
|
||||
const brainBtn = screen.getByRole('button', { name: 'Brain' });
|
||||
expect(brainBtn).toBeInTheDocument();
|
||||
expect(brainBtn).toHaveAttribute('data-walkthrough', 'tab-brain');
|
||||
expect(brainBtn).toHaveClass('brain-fab');
|
||||
const assistantBtn = screen.getByRole('button', { name: 'Assistant' });
|
||||
expect(assistantBtn).toBeInTheDocument();
|
||||
expect(assistantBtn).toHaveAttribute('data-walkthrough', 'tab-chat');
|
||||
expect(assistantBtn).toHaveClass('center-fab');
|
||||
});
|
||||
|
||||
it('navigates to /brain and tracks the change when the Brain button is clicked', async () => {
|
||||
it('navigates to /chat and tracks the change when the Assistant center button is clicked', async () => {
|
||||
const { trackEvent } = await import('../../services/analytics');
|
||||
await renderBottomTabBar('/home');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Brain' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||
|
||||
expect(trackEvent).toHaveBeenCalledWith('tab_bar_change', {
|
||||
from_tab: 'home',
|
||||
to_tab: 'brain',
|
||||
to_tab: 'chat',
|
||||
from_path: '/home',
|
||||
to_path: '/brain',
|
||||
to_path: '/chat',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,11 +241,13 @@ describe('BottomTabBar', () => {
|
||||
expect(screen.getByRole('button', { name: 'Activity' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Assistant tab (was Chat, Phase 6 rename)', async () => {
|
||||
it('renders the Brain tab in the regular row with data-walkthrough="tab-brain"', async () => {
|
||||
await renderBottomTabBar('/home');
|
||||
const assistantBtn = screen.getByRole('button', { name: 'Assistant' });
|
||||
expect(assistantBtn).toBeInTheDocument();
|
||||
expect(assistantBtn).toHaveAttribute('data-walkthrough', 'tab-chat');
|
||||
const brainBtn = screen.getByRole('button', { name: 'Brain' });
|
||||
expect(brainBtn).toBeInTheDocument();
|
||||
expect(brainBtn).toHaveAttribute('data-walkthrough', 'tab-brain');
|
||||
// It's a regular pill tab now, not the raised center FAB.
|
||||
expect(brainBtn).not.toHaveClass('center-fab');
|
||||
});
|
||||
|
||||
it('renders the Connections tab with data-walkthrough="tab-connections"', async () => {
|
||||
@@ -295,18 +297,17 @@ describe('BottomTabBar', () => {
|
||||
expect(shell?.querySelector('nav')).toHaveClass('pointer-events-auto');
|
||||
});
|
||||
|
||||
it('tracks tab changes when a different tab is clicked', async () => {
|
||||
it('tracks tab changes when a different (regular row) tab is clicked', async () => {
|
||||
const { trackEvent } = await import('../../services/analytics');
|
||||
await renderBottomTabBar('/home');
|
||||
|
||||
// Tab id is still 'chat' (back-compat) even though label is now 'Assistant'.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Brain' }));
|
||||
|
||||
expect(trackEvent).toHaveBeenCalledWith('tab_bar_change', {
|
||||
from_tab: 'home',
|
||||
to_tab: 'chat',
|
||||
to_tab: 'brain',
|
||||
from_path: '/home',
|
||||
to_path: '/chat',
|
||||
to_path: '/brain',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,23 +3,24 @@
|
||||
* and AVATAR_MENU_ITEMS so regressions are caught early.
|
||||
*
|
||||
* Human tab restored as a first-class entry (after the IA Phase 6 merge into
|
||||
* Assistant). Chat tab keeps its "Assistant" label (id stays 'chat', labelKey
|
||||
* 'nav.assistant', walkthroughAttr 'tab-chat'). Nav is back to 6 tabs.
|
||||
* Assistant), so the regular row is back to 6 tabs. The Assistant (id 'chat',
|
||||
* labelKey 'nav.assistant', walkthroughAttr 'tab-chat') is no longer in the
|
||||
* row — it's the raised center FAB (`CENTER_TAB`); the Brain sits in the row.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AVATAR_MENU_ITEMS, BRAIN_TAB, NAV_TABS } from '../navConfig';
|
||||
import { AVATAR_MENU_ITEMS, CENTER_TAB, NAV_TABS } from '../navConfig';
|
||||
|
||||
describe('NAV_TABS', () => {
|
||||
it('has exactly 6 entries (Human restored as a first-class tab)', () => {
|
||||
expect(NAV_TABS).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('has the correct ids in order', () => {
|
||||
it('has the correct ids in order (Brain in the row, Assistant is the center FAB)', () => {
|
||||
expect(NAV_TABS.map(t => t.id)).toEqual([
|
||||
'home',
|
||||
'human',
|
||||
'chat',
|
||||
'brain',
|
||||
'connections',
|
||||
'activity',
|
||||
'settings',
|
||||
@@ -30,7 +31,7 @@ describe('NAV_TABS', () => {
|
||||
expect(NAV_TABS.map(t => t.path)).toEqual([
|
||||
'/home',
|
||||
'/human',
|
||||
'/chat',
|
||||
'/brain',
|
||||
'/connections',
|
||||
'/activity',
|
||||
'/settings',
|
||||
@@ -41,7 +42,7 @@ describe('NAV_TABS', () => {
|
||||
expect(NAV_TABS.map(t => t.labelKey)).toEqual([
|
||||
'nav.home',
|
||||
'nav.human',
|
||||
'nav.assistant',
|
||||
'nav.brain',
|
||||
'nav.connections',
|
||||
'nav.activity',
|
||||
'nav.settings',
|
||||
@@ -52,7 +53,7 @@ describe('NAV_TABS', () => {
|
||||
expect(NAV_TABS.map(t => t.walkthroughAttr)).toEqual([
|
||||
'tab-home',
|
||||
'tab-human',
|
||||
'tab-chat',
|
||||
'tab-brain',
|
||||
'tab-connections',
|
||||
'tab-activity',
|
||||
'tab-settings',
|
||||
@@ -76,26 +77,26 @@ describe('NAV_TABS', () => {
|
||||
expect(NAV_TABS.find(t => t.id === 'skills')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not contain the Brain tab (rendered specially, not in the regular row)', () => {
|
||||
expect(NAV_TABS.find(t => t.id === 'brain')).toBeUndefined();
|
||||
it('does not contain the Assistant/chat tab (rendered specially as the center FAB)', () => {
|
||||
expect(NAV_TABS.find(t => t.id === 'chat')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Assistant tab uses nav.assistant label key and tab-chat walkthrough attr', () => {
|
||||
const assistantTab = NAV_TABS.find(t => t.id === 'chat');
|
||||
expect(assistantTab).toBeDefined();
|
||||
expect(assistantTab?.labelKey).toBe('nav.assistant');
|
||||
expect(assistantTab?.walkthroughAttr).toBe('tab-chat');
|
||||
expect(assistantTab?.path).toBe('/chat');
|
||||
it('Brain tab sits in the regular row with nav.brain label and tab-brain walkthrough attr', () => {
|
||||
const brainTab = NAV_TABS.find(t => t.id === 'brain');
|
||||
expect(brainTab).toBeDefined();
|
||||
expect(brainTab?.labelKey).toBe('nav.brain');
|
||||
expect(brainTab?.walkthroughAttr).toBe('tab-brain');
|
||||
expect(brainTab?.path).toBe('/brain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BRAIN_TAB', () => {
|
||||
it('has the expected shape (id, path, labelKey, walkthroughAttr)', () => {
|
||||
expect(BRAIN_TAB).toEqual({
|
||||
id: 'brain',
|
||||
labelKey: 'nav.brain',
|
||||
path: '/brain',
|
||||
walkthroughAttr: 'tab-brain',
|
||||
describe('CENTER_TAB', () => {
|
||||
it('is the Assistant — expected shape (id, path, labelKey, walkthroughAttr)', () => {
|
||||
expect(CENTER_TAB).toEqual({
|
||||
id: 'chat',
|
||||
labelKey: 'nav.assistant',
|
||||
path: '/chat',
|
||||
walkthroughAttr: 'tab-chat',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+18
-14
@@ -21,17 +21,19 @@ export interface NavTab {
|
||||
|
||||
/**
|
||||
* Ordered list of bottom-bar tabs. Six entries:
|
||||
* home → human → chat (Assistant) → connections → activity → settings
|
||||
* home → human → brain → connections → activity → settings
|
||||
*
|
||||
* 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. The tab id stays `chat` and walkthroughAttr stays `tab-chat` for
|
||||
* back-compat with analytics and the walkthrough tour.
|
||||
* desktop. The Assistant is NOT in this row — it's the raised center FAB (see
|
||||
* `CENTER_TAB`); the Brain sits in the regular row where the Assistant briefly
|
||||
* lived. 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: 'human', labelKey: 'nav.human', path: '/human', walkthroughAttr: 'tab-human' },
|
||||
{ id: 'chat', labelKey: 'nav.assistant', path: '/chat', walkthroughAttr: 'tab-chat' },
|
||||
{ id: 'brain', labelKey: 'nav.brain', path: '/brain', walkthroughAttr: 'tab-brain' },
|
||||
{
|
||||
id: 'connections',
|
||||
labelKey: 'nav.connections',
|
||||
@@ -43,17 +45,19 @@ export const NAV_TABS: NavTab[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* The "Brain" — the app's centerpiece memory-graph surface. Rendered
|
||||
* specially by BottomTabBar.tsx as a raised circular button in the dead
|
||||
* center of the bar (a notch the button rises out of), NOT as part of the
|
||||
* regular `NAV_TABS` row. Kept separate so its special, elevated nature is
|
||||
* explicit and the 6-tab invariants for the regular row stay intact.
|
||||
* The "Assistant" — the app's centerpiece chat surface. Rendered specially by
|
||||
* BottomTabBar.tsx as a raised circular button in the dead center of the bar
|
||||
* (a notch the button rises out of), NOT as part of the regular `NAV_TABS`
|
||||
* row. Kept separate so its special, elevated nature is explicit and the
|
||||
* 6-tab invariants for the regular row stay intact. The tab id stays `chat`
|
||||
* and walkthroughAttr stays `tab-chat` for back-compat with analytics and the
|
||||
* walkthrough tour.
|
||||
*/
|
||||
export const BRAIN_TAB: NavTab = {
|
||||
id: 'brain',
|
||||
labelKey: 'nav.brain',
|
||||
path: '/brain',
|
||||
walkthroughAttr: 'tab-brain',
|
||||
export const CENTER_TAB: NavTab = {
|
||||
id: 'chat',
|
||||
labelKey: 'nav.assistant',
|
||||
path: '/chat',
|
||||
walkthroughAttr: 'tab-chat',
|
||||
};
|
||||
|
||||
// ── Avatar / account menu ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -549,10 +549,4 @@
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Brain center button — silence the active glow-pulse. The Brain page's
|
||||
loading Lottie is already skipped in JS via prefers-reduced-motion. */
|
||||
.brain-fab {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user