feat(nav): make Assistant the center FAB, move Brain into the tab row (#3593)

This commit is contained in:
Cyrus Gray
2026-06-11 18:10:10 +05:30
committed by GitHub
parent 324146b491
commit 70210a2283
5 changed files with 96 additions and 92 deletions
+32 -28
View File
@@ -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',
});
});
+24 -23
View File
@@ -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
View File
@@ -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 ─────────────────────────────────────────────────────
-6
View File
@@ -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;
}
}