mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(nav): restore Human as a first-class bottom-bar tab (#3592)
This commit is contained in:
+12
-4
@@ -4,6 +4,7 @@ import AppRoutesIOS from './AppRoutesIOS';
|
||||
import DefaultRedirect from './components/DefaultRedirect';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import PublicRoute from './components/PublicRoute';
|
||||
import HumanPage from './features/human/HumanPage';
|
||||
import { getIsMobile } from './lib/platform';
|
||||
import Accounts from './pages/Accounts';
|
||||
import Activity from './pages/Activity';
|
||||
@@ -64,10 +65,17 @@ const AppRoutes = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Phase 6 — /human merged into /chat (Assistant surface).
|
||||
Preserve the route for back-compat (deep links, iOS share sheets, etc.).
|
||||
iOS AppRoutesIOS still serves /human natively — only desktop redirects. */}
|
||||
<Route path="/human" element={<Navigate to="/chat" replace />} />
|
||||
{/* Human — first-class destination again (restored after the IA Phase 6
|
||||
merge into Assistant). Renders the Human/mascot surface. iOS serves
|
||||
/human via AppRoutesIOS. */}
|
||||
<Route
|
||||
path="/human"
|
||||
element={
|
||||
<ProtectedRoute requireAuth={true}>
|
||||
<HumanPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Brain — the centerpiece memory knowledge-graph surface, reached from
|
||||
the raised center button in the bottom bar. Full-page, graph-only. */}
|
||||
|
||||
@@ -228,17 +228,16 @@ const BottomTabBar = () => {
|
||||
trackEvent('avatar_menu_item_click', { item_id: itemId });
|
||||
};
|
||||
|
||||
// One regular pill tab. `iconOnly` renders just the glyph (no label) — used
|
||||
// for the pinned Home button so it reads as a fixed icon, like the avatar.
|
||||
// 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], iconOnly = false) => {
|
||||
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 = !iconOnly && labelsAlwaysVisible;
|
||||
const fixedWidth = labelsAlwaysVisible;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -246,11 +245,7 @@ const BottomTabBar = () => {
|
||||
onClick={() => handleTabClick(tab, active)}
|
||||
title={tab.label}
|
||||
className={`group relative flex items-center rounded-sm text-sm transition-colors duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] cursor-pointer ${
|
||||
iconOnly
|
||||
? 'h-9 w-9 justify-center'
|
||||
: fixedWidth
|
||||
? 'w-32 justify-center px-2 py-2'
|
||||
: 'px-2 py-2'
|
||||
fixedWidth ? 'w-32 justify-center px-2 py-2' : 'px-2 py-2'
|
||||
} ${
|
||||
active
|
||||
? 'bg-white dark:bg-neutral-800 text-stone-900 dark:text-neutral-100 font-semibold shadow-sm'
|
||||
@@ -272,16 +267,14 @@ const BottomTabBar = () => {
|
||||
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-blue-500 animate-pulse" />
|
||||
)}
|
||||
</span>
|
||||
{!iconOnly && (
|
||||
<span
|
||||
className={`min-w-0 overflow-hidden whitespace-nowrap transition-[max-width,margin-left,opacity] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${
|
||||
active || labelsAlwaysVisible
|
||||
? `${fixedWidth ? 'truncate ' : ''}max-w-[160px] ml-2 opacity-100`
|
||||
: 'max-w-0 ml-0 opacity-0 group-hover:max-w-[160px] group-hover:ml-2 group-hover:opacity-100 group-focus-visible:max-w-[160px] group-focus-visible:ml-2 group-focus-visible:opacity-100'
|
||||
}`}>
|
||||
{tab.label}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`min-w-0 overflow-hidden whitespace-nowrap transition-[max-width,margin-left,opacity] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${
|
||||
active || labelsAlwaysVisible
|
||||
? `${fixedWidth ? 'truncate ' : ''}max-w-[160px] ml-2 opacity-100`
|
||||
: 'max-w-0 ml-0 opacity-0 group-hover:max-w-[160px] group-hover:ml-2 group-hover:opacity-100 group-focus-visible:max-w-[160px] group-focus-visible:ml-2 group-focus-visible:opacity-100'
|
||||
}`}>
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -289,6 +282,12 @@ 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.
|
||||
//
|
||||
// `-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
|
||||
// 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) };
|
||||
@@ -300,7 +299,7 @@ const BottomTabBar = () => {
|
||||
onClick={() => handleTabClick(brainTab, active)}
|
||||
aria-label={brainTab.label}
|
||||
title={brainTab.label}
|
||||
className={`brain-fab group relative mx-1 flex h-12 w-12 -translate-y-5 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 ${
|
||||
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 ${
|
||||
active
|
||||
? 'bg-primary-600 animate-glow-pulse shadow-[0_0_16px_rgba(74,131,221,0.55)] scale-105'
|
||||
: 'bg-primary-500 hover:bg-primary-600 hover:scale-105'
|
||||
@@ -310,12 +309,11 @@ const BottomTabBar = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Home is pinned to the far-left of the pill behind a divider — mirroring the
|
||||
// avatar pinned to the far-right behind its own divider. The rest of the row
|
||||
// splits evenly around the centered Brain button:
|
||||
// [ home ] | assistant · connections ( 🧠 ) activity · settings | [ avatar ]
|
||||
const homeTab = tabs[0];
|
||||
const leftTabs = tabs.slice(1, 3);
|
||||
// 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 ]
|
||||
const leftTabs = tabs.slice(0, 3);
|
||||
const rightTabs = tabs.slice(3);
|
||||
|
||||
return (
|
||||
@@ -342,9 +340,6 @@ const BottomTabBar = () => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setRevealed(false);
|
||||
}}>
|
||||
<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">
|
||||
<div className="relative mr-1 border-r border-stone-300 pr-1 dark:border-neutral-700">
|
||||
{renderTab(homeTab, true)}
|
||||
</div>
|
||||
{leftTabs.map(tab => renderTab(tab))}
|
||||
{renderBrainButton()}
|
||||
{rightTabs.map(tab => renderTab(tab))}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* Tests for BottomTabBar — verifies that:
|
||||
* - 5 tabs are rendered (no Rewards tab, no Human tab), Activity label is present
|
||||
* - 6 tabs are rendered (no Rewards tab; Human restored), Activity label is present
|
||||
* - Assistant tab is present (was "Chat", id stays 'chat', label now 'Assistant')
|
||||
* - Walkthrough attributes reflect the new ids (tab-connections, tab-activity)
|
||||
* - Avatar menu opens and shows Account / Billing / Rewards / Invites / Wallet
|
||||
* - Clicking an avatar menu item navigates or opens URL
|
||||
* - The bar is hidden on '/' and '/login' paths
|
||||
*
|
||||
* Updated for IA Phase 6: Human tab removed; Chat renamed to Assistant.
|
||||
* Human tab restored as a first-class entry (after the IA Phase 6 merge into
|
||||
* Assistant); Chat keeps its "Assistant" label.
|
||||
*/
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
@@ -19,6 +20,7 @@ import accountsReducer from '../../store/accountsSlice';
|
||||
import agentProfileReducer, { setAgentProfilesFromResponse } from '../../store/agentProfileSlice';
|
||||
import companionReducer from '../../store/companionSlice';
|
||||
import notificationReducer from '../../store/notificationSlice';
|
||||
import themeReducer, { setTabBarLabels, type TabBarLabels } from '../../store/themeSlice';
|
||||
import BottomTabBar from '../BottomTabBar';
|
||||
|
||||
// ── Module-level mocks ─────────────────────────────────────────────────────
|
||||
@@ -49,6 +51,7 @@ vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn().mockResolvedValue(undef
|
||||
|
||||
interface BuildStoreOpts {
|
||||
companionSessionActive?: boolean;
|
||||
tabBarLabels?: TabBarLabels;
|
||||
}
|
||||
|
||||
const testProfiles = {
|
||||
@@ -86,6 +89,7 @@ function buildStore(opts: BuildStoreOpts = {}) {
|
||||
notifications: notificationReducer,
|
||||
companion: companionReducer,
|
||||
agentProfiles: agentProfileReducer,
|
||||
theme: themeReducer,
|
||||
},
|
||||
});
|
||||
store.dispatch(setAgentProfilesFromResponse(testProfiles));
|
||||
@@ -95,6 +99,9 @@ function buildStore(opts: BuildStoreOpts = {}) {
|
||||
payload: { active: true, sessionId: 'sess-test' },
|
||||
});
|
||||
}
|
||||
if (opts.tabBarLabels) {
|
||||
store.dispatch(setTabBarLabels(opts.tabBarLabels));
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
@@ -103,6 +110,7 @@ interface RenderOpts {
|
||||
companionSessionActive?: boolean;
|
||||
tokenValue?: string;
|
||||
currentUser?: unknown;
|
||||
tabBarLabels?: TabBarLabels;
|
||||
}
|
||||
|
||||
async function renderBottomTabBar(pathname = '/home', opts: RenderOpts | boolean = {}) {
|
||||
@@ -138,7 +146,10 @@ async function renderBottomTabBar(pathname = '/home', opts: RenderOpts | boolean
|
||||
refreshSnapshot: vi.fn(),
|
||||
} as never);
|
||||
|
||||
const store = buildStore({ companionSessionActive: resolved.companionSessionActive });
|
||||
const store = buildStore({
|
||||
companionSessionActive: resolved.companionSessionActive,
|
||||
tabBarLabels: resolved.tabBarLabels,
|
||||
});
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={[pathname]}>
|
||||
@@ -156,7 +167,7 @@ describe('BottomTabBar', () => {
|
||||
agentProfilesApiMock.select.mockResolvedValue(testProfiles);
|
||||
});
|
||||
|
||||
it('renders exactly 5 regular tab buttons (Brain is rendered separately)', async () => {
|
||||
it('renders exactly 6 regular tab buttons (Brain 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).
|
||||
@@ -164,7 +175,17 @@ describe('BottomTabBar', () => {
|
||||
const navButtons = nav?.querySelectorAll(
|
||||
'button:not([aria-haspopup]):not([data-walkthrough="tab-brain"])'
|
||||
);
|
||||
expect(navButtons).toHaveLength(5);
|
||||
expect(navButtons).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('gives every labelled tab a fixed width when labels are always visible', async () => {
|
||||
await renderBottomTabBar('/home', { tabBarLabels: 'always' });
|
||||
// With the "always show labels" theme setting, each regular tab is given the
|
||||
// same fixed width (w-32) and its label is shown with a truncating class so
|
||||
// the row stays symmetric — this exercises the `labelsAlwaysVisible` branch.
|
||||
const humanBtn = screen.getByRole('button', { name: 'Human' });
|
||||
expect(humanBtn).toHaveClass('w-32');
|
||||
expect(humanBtn.querySelector('.truncate')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the raised Brain button with data-walkthrough="tab-brain"', async () => {
|
||||
@@ -194,9 +215,25 @@ describe('BottomTabBar', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Rewards' })).toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT render a Human tab (Phase 6: merged into Assistant)', async () => {
|
||||
it('renders the Human tab (restored as a first-class entry)', async () => {
|
||||
await renderBottomTabBar('/home');
|
||||
expect(screen.queryByRole('button', { name: 'Human' })).toBeNull();
|
||||
const humanBtn = screen.getByRole('button', { name: 'Human' });
|
||||
expect(humanBtn).toBeInTheDocument();
|
||||
expect(humanBtn).toHaveAttribute('data-walkthrough', 'tab-human');
|
||||
});
|
||||
|
||||
it('navigates to /human and tracks the change when the Human tab is clicked', async () => {
|
||||
const { trackEvent } = await import('../../services/analytics');
|
||||
await renderBottomTabBar('/home');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Human' }));
|
||||
|
||||
expect(trackEvent).toHaveBeenCalledWith('tab_bar_change', {
|
||||
from_tab: 'home',
|
||||
to_tab: 'human',
|
||||
from_path: '/home',
|
||||
to_path: '/human',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the Activity tab', async () => {
|
||||
|
||||
@@ -2,22 +2,23 @@
|
||||
* Tests for navConfig — verifies the shape, count, and key values of NAV_TABS
|
||||
* and AVATAR_MENU_ITEMS so regressions are caught early.
|
||||
*
|
||||
* Phase 6 update: Human tab removed; Chat tab renamed to "Assistant"
|
||||
* (id stays 'chat', labelKey 'nav.assistant', walkthroughAttr 'tab-chat').
|
||||
* Nav drops from 6 tabs to 5.
|
||||
* 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.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AVATAR_MENU_ITEMS, BRAIN_TAB, NAV_TABS } from '../navConfig';
|
||||
|
||||
describe('NAV_TABS', () => {
|
||||
it('has exactly 5 entries (Phase 6: Human merged into Assistant)', () => {
|
||||
expect(NAV_TABS).toHaveLength(5);
|
||||
it('has exactly 6 entries (Human restored as a first-class tab)', () => {
|
||||
expect(NAV_TABS).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('has the correct ids in order', () => {
|
||||
expect(NAV_TABS.map(t => t.id)).toEqual([
|
||||
'home',
|
||||
'human',
|
||||
'chat',
|
||||
'connections',
|
||||
'activity',
|
||||
@@ -28,6 +29,7 @@ describe('NAV_TABS', () => {
|
||||
it('has the correct paths', () => {
|
||||
expect(NAV_TABS.map(t => t.path)).toEqual([
|
||||
'/home',
|
||||
'/human',
|
||||
'/chat',
|
||||
'/connections',
|
||||
'/activity',
|
||||
@@ -38,6 +40,7 @@ describe('NAV_TABS', () => {
|
||||
it('has the correct labelKeys', () => {
|
||||
expect(NAV_TABS.map(t => t.labelKey)).toEqual([
|
||||
'nav.home',
|
||||
'nav.human',
|
||||
'nav.assistant',
|
||||
'nav.connections',
|
||||
'nav.activity',
|
||||
@@ -48,6 +51,7 @@ describe('NAV_TABS', () => {
|
||||
it('has the correct walkthroughAttrs', () => {
|
||||
expect(NAV_TABS.map(t => t.walkthroughAttr)).toEqual([
|
||||
'tab-home',
|
||||
'tab-human',
|
||||
'tab-chat',
|
||||
'tab-connections',
|
||||
'tab-activity',
|
||||
@@ -55,8 +59,12 @@ describe('NAV_TABS', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not contain a Human tab (Phase 6: merged into Assistant)', () => {
|
||||
expect(NAV_TABS.find(t => t.id === 'human')).toBeUndefined();
|
||||
it('contains a Human tab pointing at /human', () => {
|
||||
const humanTab = NAV_TABS.find(t => t.id === 'human');
|
||||
expect(humanTab).toBeDefined();
|
||||
expect(humanTab?.path).toBe('/human');
|
||||
expect(humanTab?.labelKey).toBe('nav.human');
|
||||
expect(humanTab?.walkthroughAttr).toBe('tab-human');
|
||||
});
|
||||
|
||||
it('does not contain a rewards tab', () => {
|
||||
|
||||
@@ -20,16 +20,17 @@ export interface NavTab {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered list of bottom-bar tabs. Exactly 5 entries (Phase 6: Human merged
|
||||
* into Assistant):
|
||||
* home → chat (Assistant) → connections → activity → settings
|
||||
* Ordered list of bottom-bar tabs. Six entries:
|
||||
* home → human → chat (Assistant) → connections → activity → settings
|
||||
*
|
||||
* The tab id stays `chat` and walkthroughAttr stays `tab-chat` for
|
||||
* back-compat with analytics and the walkthrough tour. The Human tab has been
|
||||
* retired; `/human` redirects to `/chat`.
|
||||
* 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.
|
||||
*/
|
||||
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: 'connections',
|
||||
@@ -46,7 +47,7 @@ export const NAV_TABS: NavTab[] = [
|
||||
* 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 5-tab invariants for the regular row stay intact.
|
||||
* explicit and the 6-tab invariants for the regular row stay intact.
|
||||
*/
|
||||
export const BRAIN_TAB: NavTab = {
|
||||
id: 'brain',
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* Phase 6 — /human → /chat redirect test.
|
||||
* Desktop /human + /chat route test.
|
||||
*
|
||||
* Verifies that the desktop route for /human redirects to /chat (Assistant
|
||||
* surface). iOS keeps /human as a real route in AppRoutesIOS.tsx — only
|
||||
* the desktop redirect is tested here.
|
||||
* The Human tab was briefly merged into /chat (IA Phase 6) and then restored as
|
||||
* a first-class destination: /human now renders the Human surface directly
|
||||
* rather than redirecting to /chat. This verifies both routes render their own
|
||||
* page on desktop. iOS keeps /human as a real route in AppRoutesIOS.tsx.
|
||||
*
|
||||
* Uses a minimal route tree so no full provider chain is needed.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function TestRoutes() {
|
||||
@@ -16,8 +17,8 @@ function TestRoutes() {
|
||||
<Routes>
|
||||
{/* The real /chat route (Assistant surface). */}
|
||||
<Route path="/chat" element={<div data-testid="chat-page">chat</div>} />
|
||||
{/* Phase 6 back-compat redirect. */}
|
||||
<Route path="/human" element={<Navigate to="/chat" replace />} />
|
||||
{/* /human renders the Human surface directly (no longer a redirect). */}
|
||||
<Route path="/human" element={<div data-testid="human-page">human</div>} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -29,10 +30,11 @@ const renderAt = (path: string) =>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe('Phase 6 route redirects', () => {
|
||||
it('/human redirects to /chat (Assistant surface)', () => {
|
||||
describe('Desktop /human + /chat routes', () => {
|
||||
it('/human renders the Human page directly (restored, not a redirect)', () => {
|
||||
renderAt('/human');
|
||||
expect(screen.getByTestId('chat-page')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('human-page')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('chat-page')).toBeNull();
|
||||
});
|
||||
|
||||
it('/chat renders the chat page directly', () => {
|
||||
|
||||
@@ -10,12 +10,12 @@ interface RouteEntry {
|
||||
|
||||
// Phase 2/3/6 IA revamp routes.
|
||||
// Back-compat redirects are included so the router redirect itself is tested.
|
||||
// /human → /chat (Phase 6)
|
||||
// /human → renders the Human surface (first-class route, restored)
|
||||
// /skills → /connections (Phase 2)
|
||||
// /intelligence → /activity (Phase 3)
|
||||
const ROUTES: RouteEntry[] = [
|
||||
{ route: '/home' },
|
||||
{ route: '/human', expectedHash: '/chat' }, // back-compat redirect
|
||||
{ route: '/human' }, // first-class route again (no longer redirects to /chat)
|
||||
{ route: '/chat' },
|
||||
{ route: '/connections' },
|
||||
{ route: '/skills', expectedHash: '/connections' }, // back-compat redirect
|
||||
|
||||
Reference in New Issue
Block a user