diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx
index 4a369c82..2ac4588e 100644
--- a/apps/console/src/App.tsx
+++ b/apps/console/src/App.tsx
@@ -62,6 +62,7 @@ const HomeLayout = lazy(() => import('./pages/home/HomeLayout').then(m => ({ def
import { ThemeProvider } from './components/theme-provider';
import { ConsoleToaster } from './components/ConsoleToaster';
+import { NavigationProvider } from './context/NavigationContext';
/**
* ConnectedShell
@@ -518,6 +519,7 @@ export function App() {
+
}>
@@ -569,6 +571,7 @@ export function App() {
+
);
diff --git a/apps/console/src/__tests__/HomeLayout.test.tsx b/apps/console/src/__tests__/HomeLayout.test.tsx
index 13bda676..8ec20ef3 100644
--- a/apps/console/src/__tests__/HomeLayout.test.tsx
+++ b/apps/console/src/__tests__/HomeLayout.test.tsx
@@ -1,12 +1,12 @@
/**
- * Tests for HomeLayout — lightweight nav shell for /home.
- * Validates: layout rendering, user avatar, navigation links.
+ * Tests for HomeLayout — unified sidebar nav shell for /home.
+ * Validates: layout rendering, sidebar presence, navigation context.
*
* Note: Radix DropdownMenu portal rendering is limited in jsdom,
* so we test the trigger and visible elements rather than dropdown contents.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen, fireEvent } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter } from 'react-router-dom';
import { HomeLayout } from '../pages/home/HomeLayout';
@@ -40,6 +40,9 @@ vi.mock('@object-ui/i18n', () => ({
direction: 'ltr',
i18n: {},
}),
+ useObjectLabel: () => ({
+ objectLabel: ({ name, label }: any) => label || name,
+ }),
}));
// Mock @object-ui/components to keep most components
@@ -51,6 +54,77 @@ vi.mock('@object-ui/components', async (importOriginal) => {
};
});
+// Mock @object-ui/layout AppShell
+vi.mock('@object-ui/layout', () => ({
+ AppShell: ({ children, sidebar }: any) => (
+
+
{sidebar}
+
{children}
+
+ ),
+ useAppShellBranding: () => {},
+}));
+
+// Mock NavigationContext
+const mockSetContext = vi.fn();
+vi.mock('../context/NavigationContext', () => ({
+ useNavigationContext: () => ({
+ context: 'home',
+ setContext: mockSetContext,
+ currentAppName: undefined,
+ setCurrentAppName: vi.fn(),
+ }),
+}));
+
+// Mock MetadataProvider
+vi.mock('../context/MetadataProvider', () => ({
+ useMetadata: () => ({
+ apps: [],
+ objects: [],
+ loading: false,
+ }),
+}));
+
+// Mock other required contexts
+vi.mock('../context/ExpressionProvider', () => ({
+ useExpressionContext: () => ({
+ evaluator: {},
+ }),
+ evaluateVisibility: () => true,
+}));
+
+vi.mock('@object-ui/permissions', () => ({
+ usePermissions: () => ({
+ can: () => true,
+ }),
+}));
+
+vi.mock('../hooks/useRecentItems', () => ({
+ useRecentItems: () => ({
+ recentItems: [],
+ addRecentItem: vi.fn(),
+ }),
+}));
+
+vi.mock('../hooks/useFavorites', () => ({
+ useFavorites: () => ({
+ favorites: [],
+ addFavorite: vi.fn(),
+ removeFavorite: vi.fn(),
+ }),
+}));
+
+vi.mock('../hooks/useNavPins', () => ({
+ useNavPins: () => ({
+ togglePin: vi.fn(),
+ applyPins: (items: any[]) => items,
+ }),
+}));
+
+vi.mock('../hooks/useResponsiveSidebar', () => ({
+ useResponsiveSidebar: () => {},
+}));
+
describe('HomeLayout', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -64,57 +138,20 @@ describe('HomeLayout', () => {
);
};
- it('renders the layout shell with data-testid', () => {
+ it('renders the AppShell with sidebar and content', () => {
renderLayout();
- expect(screen.getByTestId('home-layout')).toBeInTheDocument();
+ expect(screen.getByTestId('app-shell')).toBeInTheDocument();
+ expect(screen.getByTestId('sidebar')).toBeInTheDocument();
+ expect(screen.getByTestId('content')).toBeInTheDocument();
});
- it('renders the Home branding button in the top bar', () => {
+ it('sets navigation context to "home" on mount', () => {
renderLayout();
- const brand = screen.getByTestId('home-layout-brand');
- expect(brand).toBeInTheDocument();
- expect(brand).toHaveTextContent('Home');
+ expect(mockSetContext).toHaveBeenCalledWith('home');
});
it('renders children inside the layout', () => {
renderLayout(Hello World
);
expect(screen.getByTestId('child-content')).toBeInTheDocument();
});
-
- it('renders user avatar with initials fallback', () => {
- renderLayout();
- // The avatar trigger should show user initials "AD" (Alice Dev)
- expect(screen.getByTestId('home-layout-user-trigger')).toBeInTheDocument();
- expect(screen.getByText('AD')).toBeInTheDocument();
- });
-
- it('renders Settings button in the top bar', () => {
- renderLayout();
- expect(screen.getByTestId('home-layout-settings-btn')).toBeInTheDocument();
- });
-
- it('navigates to /system when Settings button is clicked', () => {
- renderLayout();
- fireEvent.click(screen.getByTestId('home-layout-settings-btn'));
- expect(mockNavigate).toHaveBeenCalledWith('/system');
- });
-
- it('navigates to /home when brand button is clicked', () => {
- renderLayout();
- fireEvent.click(screen.getByTestId('home-layout-brand'));
- expect(mockNavigate).toHaveBeenCalledWith('/home');
- });
-
- it('renders sticky header element', () => {
- renderLayout();
- const header = screen.getByTestId('home-layout').querySelector('header');
- expect(header).toBeInTheDocument();
- expect(header?.className).toContain('sticky');
- });
-
- it('renders user menu trigger as a round button', () => {
- renderLayout();
- const trigger = screen.getByTestId('home-layout-user-trigger');
- expect(trigger.className).toContain('rounded-full');
- });
});
diff --git a/apps/console/src/components/ConsoleLayout.tsx b/apps/console/src/components/ConsoleLayout.tsx
index 8e5f2391..b74985fa 100644
--- a/apps/console/src/components/ConsoleLayout.tsx
+++ b/apps/console/src/components/ConsoleLayout.tsx
@@ -2,18 +2,20 @@
* ConsoleLayout
*
* Root layout shell for the console application. Composes the AppShell
- * with the sidebar, header, and main content area.
+ * with the UnifiedSidebar, header, and main content area.
* Includes the global floating chatbot (FAB) widget.
+ * Sets navigation context to 'app' for app-specific routes.
* @module
*/
-import React from 'react';
+import React, { useEffect } from 'react';
import { AppShell } from '@object-ui/layout';
import { FloatingChatbot, useObjectChat, type ChatMessage } from '@object-ui/plugin-chatbot';
import { useDiscovery } from '@object-ui/react';
-import { AppSidebar } from './AppSidebar';
+import { UnifiedSidebar } from './UnifiedSidebar';
import { AppHeader } from './AppHeader';
import { useResponsiveSidebar } from '../hooks/useResponsiveSidebar';
+import { useNavigationContext } from '../context/NavigationContext';
import { resolveI18nLabel } from '../utils';
import type { ConnectionState } from '../dataSource';
@@ -88,9 +90,9 @@ function ConsoleFloatingChatbot({ appLabel, objects }: { appLabel: string; objec
);
}
-export function ConsoleLayout({
- children,
- activeAppName,
+export function ConsoleLayout({
+ children,
+ activeAppName,
activeApp,
onAppChange,
objects,
@@ -98,18 +100,25 @@ export function ConsoleLayout({
}: ConsoleLayoutProps) {
const appLabel = resolveI18nLabel(activeApp?.label) || activeAppName;
const { isAiEnabled } = useDiscovery();
+ const { setContext, setCurrentAppName } = useNavigationContext();
+
+ // Set navigation context to 'app' when this layout mounts
+ useEffect(() => {
+ setContext('app');
+ setCurrentAppName(activeAppName);
+ }, [setContext, setCurrentAppName, activeAppName]);
return (
}
navbar={
-
diff --git a/apps/console/src/components/UnifiedSidebar.tsx b/apps/console/src/components/UnifiedSidebar.tsx
new file mode 100644
index 00000000..a29c4b36
--- /dev/null
+++ b/apps/console/src/components/UnifiedSidebar.tsx
@@ -0,0 +1,680 @@
+/**
+ * UnifiedSidebar
+ *
+ * Airtable-style contextual sidebar that dynamically switches between Home and App navigation.
+ * Features:
+ * - Persistent across all authenticated routes
+ * - Context-aware navigation (Home vs App)
+ * - Pinned bottom area (Settings, Help, User Profile)
+ * - Smooth transitions between contexts
+ * - Back to Home navigation from App context
+ * - App switcher dropdown
+ *
+ * @module
+ */
+
+import * as React from 'react';
+import { useNavigate, Link, useLocation } from 'react-router-dom';
+import * as LucideIcons from 'lucide-react';
+import {
+ Sidebar,
+ SidebarHeader,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupLabel,
+ SidebarGroupContent,
+ SidebarMenu,
+ SidebarMenuItem,
+ SidebarMenuButton,
+ SidebarMenuAction,
+ SidebarInput,
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuGroup,
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ useSidebar,
+ Button,
+} from '@object-ui/components';
+import {
+ ChevronsUpDown,
+ Plus,
+ Settings,
+ LogOut,
+ Database,
+ Clock,
+ Star,
+ StarOff,
+ Search,
+ Pencil,
+ ChevronRight,
+ Home,
+ Grid3x3,
+ HelpCircle,
+ ArrowLeft,
+ Layers,
+} from 'lucide-react';
+import { NavigationRenderer } from '@object-ui/layout';
+import type { NavigationItem } from '@object-ui/types';
+import { useMetadata } from '../context/MetadataProvider';
+import { useExpressionContext, evaluateVisibility } from '../context/ExpressionProvider';
+import { useAuth, getUserInitials } from '@object-ui/auth';
+import { usePermissions } from '@object-ui/permissions';
+import { useRecentItems } from '../hooks/useRecentItems';
+import { useFavorites } from '../hooks/useFavorites';
+import { useNavPins } from '../hooks/useNavPins';
+import { resolveI18nLabel } from '../utils';
+import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
+import { useNavigationContext } from '../context/NavigationContext';
+
+// ---------------------------------------------------------------------------
+// useNavOrder – localStorage-persisted drag-and-drop reorder for nav items
+// ---------------------------------------------------------------------------
+
+function useNavOrder(appName: string) {
+ const storageKey = `objectui-nav-order-${appName}`;
+
+ const [orderMap, setOrderMap] = React.useState>(() => {
+ try {
+ const raw = localStorage.getItem(storageKey);
+ if (!raw) return {};
+ const parsed: unknown = JSON.parse(raw);
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {};
+ const result: Record = {};
+ for (const [k, v] of Object.entries(parsed as Record)) {
+ if (Array.isArray(v) && v.every((i: unknown) => typeof i === 'string')) {
+ result[k] = v as string[];
+ }
+ }
+ return result;
+ } catch {
+ return {};
+ }
+ });
+
+ const persist = React.useCallback(
+ (next: Record) => {
+ setOrderMap(next);
+ try { localStorage.setItem(storageKey, JSON.stringify(next)); } catch { /* full */ }
+ },
+ [storageKey],
+ );
+
+ const applyOrder = React.useCallback(
+ (items: NavigationItem[]): NavigationItem[] => {
+ const saved = orderMap['__root__'];
+ if (!saved) return items;
+ const byId = new Map(items.map(i => [i.id, i]));
+ const ordered: NavigationItem[] = [];
+ for (const id of saved) {
+ const item = byId.get(id);
+ if (item) { ordered.push(item); byId.delete(id); }
+ }
+ byId.forEach(item => ordered.push(item));
+ return ordered;
+ },
+ [orderMap],
+ );
+
+ const handleReorder = React.useCallback(
+ (reorderedItems: NavigationItem[]) => {
+ const ids = reorderedItems.map(i => i.id);
+ persist({ ...orderMap, __root__: ids });
+ },
+ [orderMap, persist],
+ );
+
+ return { applyOrder, handleReorder };
+}
+
+/**
+ * Resolve a Lucide icon component by name string.
+ */
+function getIcon(name?: string): React.ComponentType {
+ if (!name) return LucideIcons.Database;
+
+ if ((LucideIcons as any)[name]) {
+ return (LucideIcons as any)[name];
+ }
+
+ const pascalName = name
+ .split('-')
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
+ .join('');
+
+ if ((LucideIcons as any)[pascalName]) {
+ return (LucideIcons as any)[pascalName];
+ }
+
+ return LucideIcons.Database;
+}
+
+interface UnifiedSidebarProps {
+ /** When in app context, the active app name */
+ activeAppName?: string;
+ /** Callback when user switches apps */
+ onAppChange?: (name: string) => void;
+}
+
+export function UnifiedSidebar({ activeAppName, onAppChange }: UnifiedSidebarProps) {
+ const { isMobile } = useSidebar();
+ const { user, signOut } = useAuth();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { t } = useObjectTranslation();
+ const { objectLabel: resolveNavObjectLabel } = useObjectLabel();
+ const { context, currentAppName } = useNavigationContext();
+
+ // Swipe-from-left-edge gesture to open sidebar on mobile
+ React.useEffect(() => {
+ const EDGE_THRESHOLD = 30;
+ const SWIPE_DISTANCE = 50;
+ let touchStartX = 0;
+ const handleTouchStart = (e: TouchEvent) => {
+ touchStartX = e.touches[0].clientX;
+ };
+ const handleTouchEnd = (e: TouchEvent) => {
+ const deltaX = e.changedTouches[0].clientX - touchStartX;
+ if (touchStartX < EDGE_THRESHOLD && deltaX > SWIPE_DISTANCE && isMobile) {
+ document.querySelector('[data-sidebar="trigger"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ }
+ };
+ document.addEventListener('touchstart', handleTouchStart, { passive: true });
+ document.addEventListener('touchend', handleTouchEnd, { passive: true });
+ return () => {
+ document.removeEventListener('touchstart', handleTouchStart);
+ document.removeEventListener('touchend', handleTouchEnd);
+ };
+ }, [isMobile]);
+
+ const { recentItems } = useRecentItems();
+ const { favorites, removeFavorite } = useFavorites();
+
+ const { apps: metadataApps } = useMetadata();
+ const apps = metadataApps || [];
+ const activeApps = apps.filter((a: any) => a.active !== false);
+ const activeApp = activeApps.find((a: any) => a.name === (activeAppName || currentAppName)) || activeApps[0];
+
+ const logo = activeApp?.branding?.logo;
+ const primaryColor = activeApp?.branding?.primaryColor;
+
+ // Drag-reorder and pin persistence
+ const { applyOrder, handleReorder } = useNavOrder(activeApp?.name || 'home');
+ const { togglePin, applyPins } = useNavPins();
+
+ // Area management
+ const areas: any[] = activeApp?.areas || [];
+ const [activeAreaId, setActiveAreaId] = React.useState(
+ () => areas.length > 0 ? areas[0].id : null,
+ );
+
+ React.useEffect(() => {
+ if (areas.length > 0) {
+ setActiveAreaId(prev => areas.some((a: any) => a.id === prev) ? prev : areas[0].id);
+ } else {
+ setActiveAreaId(null);
+ }
+ }, [activeApp?.name, areas.length]);
+
+ // Resolve navigation items
+ const activeArea = areas.find((a: any) => a.id === activeAreaId);
+ const appNavigation: NavigationItem[] = activeArea?.navigation || activeApp?.navigation || [];
+
+ // Home navigation items
+ const homeNavigation: NavigationItem[] = React.useMemo(() => [
+ { id: 'home-dashboard', label: 'Home', type: 'url' as const, url: '/home', icon: 'home' },
+ ], []);
+
+ // Determine which navigation to show based on context
+ const navigationItems = context === 'home' ? homeNavigation : appNavigation;
+
+ // Apply saved order and pin state
+ const processedNavigation = React.useMemo(() => {
+ const ordered = applyOrder(navigationItems);
+ return applyPins(ordered);
+ }, [navigationItems, applyOrder, applyPins]);
+
+ // Search filter state
+ const [navSearchQuery, setNavSearchQuery] = React.useState('');
+
+ // Recent section collapsed by default
+ const [recentExpanded, setRecentExpanded] = React.useState(false);
+
+ // Visibility evaluation
+ const { evaluator } = useExpressionContext();
+ const evalVis = React.useCallback(
+ (expr: string | boolean | undefined) => evaluateVisibility(expr, evaluator),
+ [evaluator],
+ );
+
+ // Permission check
+ const { can } = usePermissions();
+ const checkPerm = React.useCallback(
+ (permissions: string[]) => permissions.every((perm: string) => {
+ const parts = perm.split(':');
+ const [object, action] = parts.length >= 2
+ ? [parts[0], parts[1]]
+ : [perm, 'read'];
+ return can(object, action as any);
+ }),
+ [can],
+ );
+
+ const basePath = context === 'app' && activeApp ? `/apps/${activeApp.name}` : '';
+
+ return (
+ <>
+
+
+
+
+ {context === 'app' && activeApp ? (
+
+
+
+
+ {logo ? (
+
+ ) : (
+ React.createElement(getIcon(activeApp.icon), { className: "size-4" })
+ )}
+
+
+ {resolveI18nLabel(activeApp.label, t)}
+
+ {resolveI18nLabel(activeApp.description, t) || `${activeApps.length} Apps Available`}
+
+
+
+
+
+
+
+ Switch Application
+
+ {activeApps.map((app: any) => (
+ onAppChange?.(app.name)}
+ className="gap-2 p-2"
+ >
+
+ {app.icon ? React.createElement(getIcon(app.icon), { className: "size-3" }) : }
+
+ {resolveI18nLabel(app.label, t)}
+ {activeApp.name === app.name && ✓ }
+
+ ))}
+
+ navigate('/home')} data-testid="home-link-btn">
+
+
+
+ Back to Home
+
+
+ navigate(`/apps/${activeApp.name}/edit-app/${activeApp.name}`)} data-testid="edit-app-btn">
+
+ Edit App
+
+
+
+ ) : (
+ /* Home context header - Workspace selector */
+
+
+
+
+
+ Workspace
+ {activeApps.length} Apps
+
+
+ )}
+
+
+
+
+
+
+ {context === 'app' && activeApp ? (
+ <>
+ {/* Back to Home button */}
+
+
+
+
+
+
+ Back to Home
+
+
+
+
+
+
+ {/* Area Switcher */}
+ {areas.length > 1 && (
+
+
+
+ Area
+
+
+
+ {areas.map((area: any) => {
+ const AreaIcon = getIcon(area.icon);
+ const isActiveArea = area.id === activeAreaId;
+ return (
+
+ setActiveAreaId(area.id)}
+ >
+
+ {area.label}
+
+
+ );
+ })}
+
+
+
+ )}
+
+ {/* Navigation Search */}
+
+
+
+ setNavSearchQuery(e.target.value)}
+ className="pl-8"
+ />
+
+
+
+ {/* App Navigation tree */}
+
resolveNavObjectLabel({ name: objectName, label: fallback })}
+ t={t}
+ />
+
+ {/* Recent Items */}
+ {recentItems.length > 0 && (
+
+ setRecentExpanded(prev => !prev)}
+ >
+
+
+ Recent
+
+ {recentExpanded && (
+
+
+ {recentItems.slice(0, 5).map(item => (
+
+
+
+
+ {item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄'}
+
+ {item.label}
+
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* Favorites */}
+ {favorites.length > 0 && (
+
+
+
+ Favorites
+
+
+
+ {favorites.slice(0, 8).map(item => (
+
+
+
+
+ {item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : item.type === 'page' ? '📄' : '📋'}
+
+ {item.label}
+
+
+ { e.stopPropagation(); removeFavorite(item.id); }}
+ aria-label={`Remove ${item.label} from favorites`}
+ >
+
+
+
+ ))}
+
+
+
+ )}
+ >
+ ) : (
+ /* Home Navigation */
+ <>
+
+
+
+ {homeNavigation.map((item) => {
+ const NavIcon = getIcon(item.icon);
+ const isActive = location.pathname === item.url;
+ return (
+
+
+
+
+ {item.label as string}
+
+
+
+ );
+ })}
+
+
+
+
+ {/* Starred Apps */}
+ {favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').length > 0 && (
+
+
+
+ Starred
+
+
+
+ {favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').slice(0, 8).map(item => (
+
+
+
+
+ {item.type === 'dashboard' ? '📊' : item.type === 'page' ? '📄' : '📋'}
+
+ {item.label}
+
+
+ { e.stopPropagation(); removeFavorite(item.id); }}
+ aria-label={`Remove ${item.label} from favorites`}
+ >
+
+
+
+ ))}
+
+
+
+ )}
+ >
+ )}
+
+
+
+ {/* Pinned Bottom Area - Always visible */}
+
+
+ {/* Settings */}
+
+
+
+
+ Settings
+
+
+
+
+ {/* Help */}
+
+
+
+
+ Help
+
+
+
+
+ {/* User Profile */}
+
+
+
+
+
+
+
+ {getUserInitials(user)}
+
+
+
+ {user?.name ?? 'User'}
+ {user?.email ?? ''}
+
+
+
+
+
+
+
+
+
+
+ {getUserInitials(user)}
+
+
+
+ {user?.name ?? 'User'}
+ {user?.email ?? ''}
+
+
+
+
+
+ navigate('/system/profile')}
+ >
+
+ Profile
+
+ navigate(context === 'app' && activeApp ? `/apps/${activeApp.name}/system` : '/system')}
+ >
+
+ Settings
+
+
+
+ signOut()}
+ >
+
+ Log out
+
+
+
+
+
+
+
+ {isMobile && context === 'app' && (
+
+ {processedNavigation.filter((n: any) => n.type !== 'group').slice(0, 5).map((item: any) => {
+ const NavIcon = getIcon(item.icon);
+ let href = item.url || '#';
+ if (item.type === 'object') {
+ href = `${basePath}/${item.objectName}`;
+ if (item.viewName) href += `/view/${item.viewName}`;
+ }
+ else if (item.type === 'dashboard') href = item.dashboardName ? `${basePath}/dashboard/${item.dashboardName}` : '#';
+ else if (item.type === 'page') href = item.pageName ? `${basePath}/page/${item.pageName}` : '#';
+ return (
+
+
+ {resolveI18nLabel(item.label, t)}
+
+ );
+ })}
+
+ )}
+ >
+ );
+}
diff --git a/apps/console/src/context/NavigationContext.tsx b/apps/console/src/context/NavigationContext.tsx
new file mode 100644
index 00000000..c0f3c82a
--- /dev/null
+++ b/apps/console/src/context/NavigationContext.tsx
@@ -0,0 +1,62 @@
+/**
+ * NavigationContext
+ *
+ * Provides global navigation state for the unified sidebar.
+ * Tracks whether the user is in "Home" context (workspace view) or "App" context (specific app).
+ * Used to determine which navigation menu to display in the UnifiedSidebar.
+ *
+ * @module
+ */
+
+import { createContext, useContext, useState, useMemo, type ReactNode } from 'react';
+
+export type NavigationContextType = 'home' | 'app';
+
+interface NavigationContextValue {
+ /** Current navigation context (home or app) */
+ context: NavigationContextType;
+ /** Set the navigation context */
+ setContext: (context: NavigationContextType) => void;
+ /** Current app name when in app context */
+ currentAppName?: string;
+ /** Set the current app name */
+ setCurrentAppName: (appName?: string) => void;
+}
+
+const NavigationContext = createContext(undefined);
+
+interface NavigationProviderProps {
+ children: ReactNode;
+}
+
+export function NavigationProvider({ children }: NavigationProviderProps) {
+ const [context, setContext] = useState('home');
+ const [currentAppName, setCurrentAppName] = useState();
+
+ const value = useMemo(
+ () => ({
+ context,
+ setContext,
+ currentAppName,
+ setCurrentAppName,
+ }),
+ [context, currentAppName]
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Hook to access navigation context
+ */
+export function useNavigationContext(): NavigationContextValue {
+ const context = useContext(NavigationContext);
+ if (!context) {
+ throw new Error('useNavigationContext must be used within a NavigationProvider');
+ }
+ return context;
+}
diff --git a/apps/console/src/pages/home/HomeLayout.tsx b/apps/console/src/pages/home/HomeLayout.tsx
index 77770ac3..4eceeed0 100644
--- a/apps/console/src/pages/home/HomeLayout.tsx
+++ b/apps/console/src/pages/home/HomeLayout.tsx
@@ -1,117 +1,53 @@
/**
* HomeLayout
*
- * Lightweight layout shell for the Home Dashboard (`/home`).
- * Provides a consistent navigation frame (header bar with Home branding
- * and user menu) so the page is not rendered "bare" and can be extended
- * in the future with notifications, global guide, or unified theming.
+ * Unified Home Dashboard layout with persistent sidebar.
+ * Uses AppShell + UnifiedSidebar for Airtable-style contextual navigation.
+ * The sidebar displays Home-context navigation (workspace-level items).
*
* @module
*/
-import React from 'react';
-import { useNavigate } from 'react-router-dom';
-import { useObjectTranslation } from '@object-ui/i18n';
-import { useAuth, getUserInitials } from '@object-ui/auth';
-import {
- Button,
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuGroup,
- Avatar,
- AvatarImage,
- AvatarFallback,
-} from '@object-ui/components';
-import { Settings, LogOut, User, Home } from 'lucide-react';
+import React, { useEffect } from 'react';
+import { AppShell } from '@object-ui/layout';
+import { UnifiedSidebar } from '../../components/UnifiedSidebar';
+import { AppHeader } from '../../components/AppHeader';
+import { useNavigationContext } from '../../context/NavigationContext';
+import { useResponsiveSidebar } from '../../hooks/useResponsiveSidebar';
interface HomeLayoutProps {
children: React.ReactNode;
}
-export function HomeLayout({ children }: HomeLayoutProps) {
- const navigate = useNavigate();
- const { t } = useObjectTranslation();
- const { user, signOut } = useAuth();
-
- return (
-
- {/* Top Navigation Bar */}
-
+ // Set navigation context to 'home' when this layout mounts
+ useEffect(() => {
+ setContext('home');
+ }, [setContext]);
- {/* Page Content */}
- {children}
-
+ return (
+ }
+ navbar={
+
+ }
+ className="p-0 overflow-hidden bg-muted/5"
+ >
+
+ {children}
+
+
);
}
diff --git a/apps/console/src/pages/home/HomePage.tsx b/apps/console/src/pages/home/HomePage.tsx
index 79e0d7c8..95e04a59 100644
--- a/apps/console/src/pages/home/HomePage.tsx
+++ b/apps/console/src/pages/home/HomePage.tsx
@@ -90,7 +90,7 @@ export function HomePage() {
return (
{/* Page Title */}
-
+
{t('home.title', { defaultValue: 'Home' })}
@@ -100,7 +100,7 @@ export function HomePage() {
{/* Main Content */}
-