feat(shell): surface wallet at a prominent location (#3836)

This commit is contained in:
Cyrus Gray
2026-06-20 00:06:42 +05:30
committed by GitHub
parent 8ae3cef789
commit c18e5ef04f
19 changed files with 152 additions and 1 deletions
@@ -19,10 +19,11 @@ vi.mock('../../../services/analytics', () => ({ trackEvent: vi.fn() }));
describe('CollapsedNavRail', () => {
beforeEach(() => vi.clearAllMocks());
it('renders Home plus every primary nav destination as an icon button', () => {
it('renders Home, Wallet, and every primary nav destination as icon buttons', () => {
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
for (const key of [
'nav.home',
'nav.wallet',
'nav.chat',
'nav.human',
'nav.brain',
@@ -33,6 +34,26 @@ describe('CollapsedNavRail', () => {
}
});
it('wallet button navigates to /settings/wallet-balances', () => {
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
fireEvent.click(screen.getByRole('button', { name: 'nav.wallet' }));
expect(mockNavigate).toHaveBeenCalledWith('/settings/wallet-balances');
});
it('wallet button has correct data-analytics-id', () => {
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
expect(screen.getByRole('button', { name: 'nav.wallet' })).toHaveAttribute(
'data-analytics-id',
'collapsed-rail-wallet'
);
});
it('wallet button is marked active when on /settings/wallet-balances', () => {
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/settings/wallet-balances'] });
const btn = screen.getByRole('button', { name: 'nav.wallet' });
expect(btn.className).toMatch(/bg-white|dark:bg-neutral-800/);
});
it('navigates to a destination path when its icon is clicked', () => {
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
fireEvent.click(screen.getByRole('button', { name: 'nav.brain' }));
@@ -67,6 +67,24 @@ export default function CollapsedNavRail() {
<NavIcon id="home" className="h-4 w-4" />
</button>
{/* Wallet shortcut — mirrors SidebarHeader wallet button for collapsed state. */}
<button
type="button"
onClick={() => navigate('/settings/wallet-balances')}
title={t('nav.wallet')}
aria-label={t('nav.wallet')}
aria-current={
matchActive('/settings/wallet-balances', location.pathname) ? 'page' : undefined
}
data-analytics-id="collapsed-rail-wallet"
className={`${RAIL_BTN} ${
matchActive('/settings/wallet-balances', location.pathname)
? 'bg-white text-stone-900 shadow-sm dark:bg-neutral-800 dark:text-neutral-100'
: 'text-stone-500 hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200'
}`}>
<NavIcon id="wallet" className="h-4 w-4" />
</button>
{/* Primary nav destinations */}
{tabs.map(tab => {
const active = matchActive(tab.path, location.pathname);
@@ -0,0 +1,69 @@
import { fireEvent, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../test/test-utils';
import SidebarHeader from './SidebarHeader';
const mockNavigate = vi.fn();
const mockHome = vi.fn();
const mockHide = vi.fn();
vi.mock('react-router-dom', async importOriginal => {
const actual = await importOriginal<typeof import('react-router-dom')>();
return { ...actual, useNavigate: () => mockNavigate };
});
vi.mock('./useHomeNav', () => ({ useHomeNav: () => mockHome }));
vi.mock('./RootShellLayout', () => ({ useRootSidebar: () => ({ hide: mockHide }) }));
// Return i18n keys verbatim so queries don't depend on locale.
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
describe('SidebarHeader', () => {
beforeEach(() => vi.clearAllMocks());
it('renders Home, Wallet, Settings, and Collapse buttons', () => {
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
expect(screen.getByRole('button', { name: 'nav.home' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'nav.wallet' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'nav.settings' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'chat.hideSidebar' })).toBeInTheDocument();
});
it('wallet button navigates to /settings/wallet-balances', () => {
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
fireEvent.click(screen.getByRole('button', { name: 'nav.wallet' }));
expect(mockNavigate).toHaveBeenCalledWith('/settings/wallet-balances');
});
it('wallet button has correct data-analytics-id', () => {
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
expect(screen.getByRole('button', { name: 'nav.wallet' })).toHaveAttribute(
'data-analytics-id',
'sidebar-header-wallet'
);
});
it('wallet button has matching aria-label and title', () => {
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
const btn = screen.getByRole('button', { name: 'nav.wallet' });
expect(btn).toHaveAttribute('aria-label', 'nav.wallet');
expect(btn).toHaveAttribute('title', 'nav.wallet');
});
it('settings button navigates to /settings', () => {
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
fireEvent.click(screen.getByRole('button', { name: 'nav.settings' }));
expect(mockNavigate).toHaveBeenCalledWith('/settings');
});
it('Home button invokes the shared Home action', () => {
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
fireEvent.click(screen.getByRole('button', { name: 'nav.home' }));
expect(mockHome).toHaveBeenCalledTimes(1);
});
it('Collapse button calls hide()', () => {
renderWithProviders(<SidebarHeader />, { initialEntries: ['/home'] });
fireEvent.click(screen.getByRole('button', { name: 'chat.hideSidebar' }));
expect(mockHide).toHaveBeenCalledTimes(1);
});
});
@@ -37,6 +37,24 @@ export default function SidebarHeader() {
</button>
<div className="flex items-center gap-0.5">
{/* Wallet shortcut — one-click access to wallet balances. */}
<button
type="button"
onClick={() => navigate('/settings/wallet-balances')}
className={ICON_BTN}
data-analytics-id="sidebar-header-wallet"
aria-label={t('nav.wallet')}
title={t('nav.wallet')}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
</button>
<button
type="button"
onClick={() => navigate('/settings')}
@@ -97,6 +97,17 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) {
/>
</svg>
);
case 'wallet':
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
);
case 'brain':
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
+1
View File
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
'nav.activity': 'النشاط',
'nav.brain': 'الدماغ',
'nav.agentWorld': 'Tiny.Place',
'nav.wallet': 'المحفظة',
'agentWorld.description':
'Tiny.Place شبكة اجتماعية لوكلاء الذكاء الاصطناعي. استخدم OpenHuman للتفاعل والعثور على الوظائف ونشرها والتداول والنمو معًا.',
'agentWorld.feed': 'التغذية',
+1
View File
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
'nav.activity': 'কার্যকলাপ',
'nav.brain': 'ব্রেইন',
'nav.agentWorld': 'Tiny.Place',
'nav.wallet': 'ওয়ালেট',
'agentWorld.description':
'Tiny.Place হলো এআই এজেন্টদের জন্য একটি সোশ্যাল নেটওয়ার্ক। যোগাযোগ করতে, কাজ খুঁজতে ও পোস্ট করতে, লেনদেন করতে এবং একসাথে বেড়ে উঠতে OpenHuman ব্যবহার করুন।',
'agentWorld.feed': 'ফিড',
+1
View File
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
'nav.activity': 'Aktivität',
'nav.brain': 'Gehirn',
'nav.agentWorld': 'Tiny.Place',
'nav.wallet': 'Wallet',
'agentWorld.description':
'Tiny.Place ist ein soziales Netzwerk für KI-Agenten. Nutze OpenHuman, um zu interagieren, Jobs zu finden und zu veröffentlichen, zu handeln und gemeinsam zu wachsen.',
'agentWorld.feed': 'Feed',
+1
View File
@@ -24,6 +24,7 @@ const en: TranslationMap = {
'nav.activity': 'Activity',
'nav.brain': 'Brain',
'nav.agentWorld': 'Tiny.Place',
'nav.wallet': 'Wallet',
// Agent World section sub-navigation labels
'agentWorld.description':
'Tiny.Place is a social network for AI agents. Use OpenHuman to interact, find and post jobs, trade, and grow together.',
+1
View File
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
'nav.activity': 'Actividad',
'nav.brain': 'Cerebro',
'nav.agentWorld': 'Tiny.Place',
'nav.wallet': 'Cartera',
'agentWorld.description':
'Tiny.Place es una red social para agentes de IA. Usa OpenHuman para interactuar, encontrar y publicar trabajos, comerciar y crecer juntos.',
'agentWorld.feed': 'Noticias',
+1
View File
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
'nav.activity': 'Activité',
'nav.brain': 'Cerveau',
'nav.agentWorld': 'Tiny.Place',
'nav.wallet': 'Portefeuille',
'agentWorld.description':
'Tiny.Place est un réseau social pour les agents IA. Utilisez OpenHuman pour interagir, trouver et publier des missions, échanger et grandir ensemble.',
'agentWorld.feed': 'Fil',
+1
View File
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
'nav.activity': 'गतिविधि',
'nav.brain': 'ब्रेन',
'nav.agentWorld': 'Tiny.Place',
'nav.wallet': 'वॉलेट',
'agentWorld.description':
'Tiny.Place एआई एजेंट्स के लिए एक सोशल नेटवर्क है। बातचीत करने, काम खोजने और पोस्ट करने, व्यापार करने और साथ मिलकर आगे बढ़ने के लिए OpenHuman का उपयोग करें।',
'agentWorld.feed': 'फ़ीड',
+1
View File
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
'nav.activity': 'Aktivitas',
'nav.brain': 'Otak',
'nav.agentWorld': 'Tiny.Place',
'nav.wallet': 'Dompet',
'agentWorld.description':
'Tiny.Place adalah jejaring sosial untuk agen AI. Gunakan OpenHuman untuk berinteraksi, menemukan dan memasang pekerjaan, berdagang, serta tumbuh bersama.',
'agentWorld.feed': 'Feed',
+1
View File
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
'nav.activity': 'Attività',
'nav.brain': 'Cervello',
'nav.agentWorld': 'Tiny.Place',
'nav.wallet': 'Portafoglio',
'agentWorld.description':
'Tiny.Place è un social network per agenti IA. Usa OpenHuman per interagire, trovare e pubblicare lavori, scambiare e crescere insieme.',
'agentWorld.feed': 'Feed',
+1
View File
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
'nav.activity': '활동',
'nav.brain': '브레인',
'nav.agentWorld': 'Tiny.Place',
'nav.wallet': '지갑',
'agentWorld.description':
'Tiny.Place는 AI 에이전트를 위한 소셜 네트워크입니다. OpenHuman을 사용해 소통하고, 일자리를 찾고 올리며, 거래하고 함께 성장하세요.',
'agentWorld.feed': '피드',
+1
View File
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
'nav.activity': 'Aktywność',
'nav.brain': 'Mózg',
'nav.agentWorld': 'Tiny.Place',
'nav.wallet': 'Portfel',
'agentWorld.description':
'Tiny.Place to sieć społecznościowa dla agentów AI. Używaj OpenHuman, aby wchodzić w interakcje, znajdować i publikować zlecenia, handlować i wspólnie się rozwijać.',
'agentWorld.feed': 'Kanał',
+1
View File
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
'nav.activity': 'Atividade',
'nav.brain': 'Cérebro',
'nav.agentWorld': 'Tiny.Place',
'nav.wallet': 'Carteira',
'agentWorld.description':
'Tiny.Place é uma rede social para agentes de IA. Use o OpenHuman para interagir, encontrar e publicar trabalhos, negociar e crescer juntos.',
'agentWorld.feed': 'Feed',
+1
View File
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
'nav.activity': 'Активность',
'nav.brain': 'Мозг',
'nav.agentWorld': 'Tiny.Place',
'nav.wallet': 'Кошелёк',
'agentWorld.description':
'Tiny.Place — это социальная сеть для ИИ-агентов. Используйте OpenHuman, чтобы взаимодействовать, находить и публиковать задания, торговать и расти вместе.',
'agentWorld.feed': 'Лента',
+1
View File
@@ -40,6 +40,7 @@ const messages: TranslationMap = {
'nav.activity': '动态',
'nav.brain': '大脑',
'nav.agentWorld': 'Tiny.Place',
'nav.wallet': '钱包',
'agentWorld.description':
'Tiny.Place 是面向 AI 智能体的社交网络。使用 OpenHuman 来互动、查找和发布工作、交易并共同成长。',
'agentWorld.feed': '动态',