Skip to content

Commit a26decd

Browse files
Merge pull request #1246 from objectstack-ai/copilot/update-menu-position
feat(console): use top navigation on /home instead of left sidebar
2 parents 6639754 + 548467e commit a26decd

6 files changed

Lines changed: 310 additions & 61 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
_No unreleased changes yet. Add new entries here._
10+
### Changed
11+
12+
- **Console home page (`/home`)** now uses a top navigation bar (`HomeTopNav`)
13+
instead of the left `UnifiedSidebar`. This visually separates the workspace
14+
landing page from individual applications (which still use `AppShell` +
15+
`UnifiedSidebar` for their rich, contextual left-hand navigation). Fixes
16+
confusion when the home page and an application page look visually
17+
identical. The top nav keeps the workspace switcher, Home link, search
18+
(⌘K), activity feed, help, theme toggle, locale switcher, and user
19+
profile menu.
20+
1121

1222
## [3.3.0] - 2026-04-17
1323

apps/console/src/__tests__/HomeLayout.test.tsx

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
/**
2-
* Tests for HomeLayout — unified sidebar nav shell for /home.
3-
* Validates: layout rendering, sidebar presence, navigation context.
2+
* Tests for HomeLayout — top-navigation shell for /home.
3+
*
4+
* The Home page deliberately uses a top menu (no left sidebar) to visually
5+
* separate the workspace landing page from individual applications, which
6+
* retain the `UnifiedSidebar` + `AppShell` layout.
47
*
58
* Note: Radix DropdownMenu portal rendering is limited in jsdom,
69
* so we test the trigger and visible elements rather than dropdown contents.
@@ -54,22 +57,10 @@ vi.mock('@object-ui/components', async (importOriginal) => {
5457
};
5558
});
5659

57-
// Mock UnifiedSidebar entirely — HomeLayout tests verify layout composition,
58-
// not the sidebar's internal rendering. This also avoids SidebarProvider
59-
// dependency when AppShell is mocked as a plain div.
60-
vi.mock('../components/UnifiedSidebar', () => ({
61-
UnifiedSidebar: () => <nav data-testid="unified-sidebar" />,
62-
}));
63-
64-
// Mock @object-ui/layout AppShell
65-
vi.mock('@object-ui/layout', () => ({
66-
AppShell: ({ children, sidebar }: any) => (
67-
<div data-testid="app-shell">
68-
<div data-testid="sidebar">{sidebar}</div>
69-
<div data-testid="content">{children}</div>
70-
</div>
71-
),
72-
useAppShellBranding: () => {},
60+
// Mock HomeTopNav — HomeLayout tests verify layout composition, not the
61+
// top nav's internal rendering (user menu, workspace switcher, etc.).
62+
vi.mock('../pages/home/HomeTopNav', () => ({
63+
HomeTopNav: () => <nav data-testid="home-top-nav" />,
7364
}));
7465

7566
// Mock NavigationContext
@@ -145,11 +136,15 @@ describe('HomeLayout', () => {
145136
);
146137
};
147138

148-
it('renders the AppShell with sidebar and content', () => {
139+
it('renders the top navigation and content area, without a sidebar', () => {
149140
renderLayout();
150-
expect(screen.getByTestId('app-shell')).toBeInTheDocument();
151-
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
152-
expect(screen.getByTestId('content')).toBeInTheDocument();
141+
expect(screen.getByTestId('home-layout')).toBeInTheDocument();
142+
expect(screen.getByTestId('home-top-nav')).toBeInTheDocument();
143+
// The home layout must NOT render the app-style left sidebar — that is
144+
// reserved for `/apps/:appName/*` to keep the two contexts visually
145+
// distinct.
146+
expect(screen.queryByTestId('unified-sidebar')).not.toBeInTheDocument();
147+
expect(screen.queryByTestId('app-shell')).not.toBeInTheDocument();
153148
});
154149

155150
it('sets navigation context to "home" on mount', () => {
Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,40 @@
11
/**
22
* HomeLayout
33
*
4-
* Unified Home Dashboard layout with persistent sidebar.
5-
* Uses AppShell + UnifiedSidebar for Airtable-style contextual navigation.
6-
* The sidebar displays Home-context navigation (workspace-level items).
4+
* Unified Home (workspace) landing page layout.
5+
*
6+
* Unlike the in-app shell at `/apps/:appName/*`, the Home page uses a
7+
* **top navigation bar only** (`HomeTopNav`) and deliberately omits the
8+
* left sidebar. This clearly separates the workspace landing page from
9+
* individual applications that *do* use `AppShell` + `UnifiedSidebar`.
710
*
811
* @module
912
*/
1013

1114
import React, { useEffect } from 'react';
12-
import { AppShell } from '@object-ui/layout';
13-
import { useObjectTranslation } from '@object-ui/i18n';
14-
import { UnifiedSidebar } from '../../components/UnifiedSidebar';
15-
import { AppHeader } from '../../components/AppHeader';
1615
import { useNavigationContext } from '../../context/NavigationContext';
17-
import { useResponsiveSidebar } from '../../hooks/useResponsiveSidebar';
16+
import { HomeTopNav } from './HomeTopNav';
1817

1918
interface HomeLayoutProps {
2019
children: React.ReactNode;
2120
}
2221

23-
/** Inner component that can access SidebarProvider context */
24-
function HomeLayoutInner({ children }: { children: React.ReactNode }) {
25-
useResponsiveSidebar();
26-
return <>{children}</>;
27-
}
28-
2922
export function HomeLayout({ children }: HomeLayoutProps) {
3023
const { setContext } = useNavigationContext();
31-
const { t } = useObjectTranslation();
3224

33-
// Set navigation context to 'home' when this layout mounts
25+
// Set navigation context to 'home' when this layout mounts so that
26+
// shared services (breadcrumbs, recent items, etc.) know we are on
27+
// the workspace landing page.
3428
useEffect(() => {
3529
setContext('home');
3630
}, [setContext]);
3731

3832
return (
39-
<AppShell
40-
sidebar={<UnifiedSidebar />}
41-
navbar={
42-
<AppHeader
43-
appName={t('home.nav', { defaultValue: 'Home' })}
44-
objects={[]}
45-
connectionState="connected"
46-
/>
47-
}
48-
className="p-0 overflow-hidden bg-muted/5"
49-
>
50-
<HomeLayoutInner>
33+
<div className="flex min-h-svh w-full flex-col bg-background" data-testid="home-layout">
34+
<HomeTopNav />
35+
<main className="flex-1 min-w-0 overflow-auto">
5136
{children}
52-
</HomeLayoutInner>
53-
</AppShell>
37+
</main>
38+
</div>
5439
);
5540
}
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/**
2+
* HomeTopNav
3+
*
4+
* Single-row top navigation bar for the Home (workspace) landing page.
5+
*
6+
* [Logo + Product] [Global search (⌘K)]
7+
* [Activity | Help] [Theme | Locale] [Workspace | User]
8+
*
9+
* The page title (rendered by `HomePage`) and any primary page actions live
10+
* in the page body, keeping the chrome compact. See
11+
* {@link ./HomeLayout.tsx} for composition.
12+
*
13+
* @module
14+
*/
15+
16+
import { Link, useNavigate } from 'react-router-dom';
17+
import {
18+
Avatar,
19+
AvatarImage,
20+
AvatarFallback,
21+
Button,
22+
DropdownMenu,
23+
DropdownMenuTrigger,
24+
DropdownMenuContent,
25+
DropdownMenuItem,
26+
DropdownMenuLabel,
27+
DropdownMenuSeparator,
28+
DropdownMenuGroup,
29+
Separator,
30+
} from '@object-ui/components';
31+
import {
32+
Search,
33+
HelpCircle,
34+
LogOut,
35+
Settings,
36+
User as UserIcon,
37+
ChevronsUpDown,
38+
Boxes,
39+
} from 'lucide-react';
40+
import { useAuth, getUserInitials } from '@object-ui/auth';
41+
import { useObjectTranslation } from '@object-ui/i18n';
42+
import { WorkspaceSwitcher } from '../../components/WorkspaceSwitcher';
43+
import { ModeToggle } from '../../components/mode-toggle';
44+
import { LocaleSwitcher } from '../../components/LocaleSwitcher';
45+
import { ActivityFeed, type ActivityItem } from '../../components/ActivityFeed';
46+
47+
interface HomeTopNavProps {
48+
/** Optional activity feed items. When omitted an empty feed is rendered. */
49+
activities?: ActivityItem[];
50+
}
51+
52+
/** Opens the global command palette via the ⌘K/CtrlK shortcut it listens for. */
53+
function openCommandPalette() {
54+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
55+
}
56+
57+
export function HomeTopNav({ activities }: HomeTopNavProps) {
58+
const { t } = useObjectTranslation();
59+
const { user, signOut } = useAuth();
60+
const navigate = useNavigate();
61+
62+
return (
63+
<header
64+
className="sticky top-0 z-40 w-full shrink-0 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 shadow-[0_1px_0_rgba(0,0,0,0.04)]"
65+
data-testid="home-top-nav"
66+
>
67+
{/* ──────────────────────────── Row 1: Global ──────────────────────────── */}
68+
<div className="flex h-12 w-full items-center gap-2 px-3 sm:px-4 md:px-6">
69+
{/* Brand */}
70+
<Link
71+
to="/home"
72+
className="flex items-center gap-2 shrink-0 rounded-md px-1.5 py-1 hover:bg-accent/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
73+
data-testid="home-brand"
74+
aria-label="ObjectStack"
75+
>
76+
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary text-primary-foreground">
77+
<Boxes className="h-4 w-4" />
78+
</div>
79+
<span className="hidden sm:inline text-sm font-semibold tracking-tight">
80+
ObjectStack
81+
</span>
82+
</Link>
83+
84+
{/* Search */}
85+
<button
86+
onClick={openCommandPalette}
87+
className="hidden lg:flex relative items-center gap-2 ml-auto w-72 xl:w-96 h-8 px-3 text-sm rounded-md border bg-muted/40 text-muted-foreground hover:bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
88+
data-testid="home-search-trigger"
89+
>
90+
<Search className="h-4 w-4 shrink-0" />
91+
<span className="flex-1 text-left truncate">
92+
{t('console.searchRich', {
93+
defaultValue: 'Search apps, objects, records…',
94+
})}
95+
</span>
96+
<kbd className="pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground">
97+
<span className="text-xs"></span>K
98+
</kbd>
99+
</button>
100+
101+
{/* Trailing icon cluster */}
102+
<div className="ml-auto lg:ml-2 flex items-center gap-0.5 sm:gap-1 shrink-0">
103+
{/* Search — mobile/tablet */}
104+
<Button
105+
variant="ghost"
106+
size="icon"
107+
className="lg:hidden h-8 w-8 shrink-0"
108+
onClick={openCommandPalette}
109+
aria-label={t('console.search', { defaultValue: 'Search...' })}
110+
>
111+
<Search className="h-4 w-4" />
112+
</Button>
113+
114+
{/* Activity */}
115+
<div className="hidden sm:flex shrink-0 relative">
116+
<ActivityFeed activities={activities ?? []} />
117+
</div>
118+
119+
{/* Help */}
120+
<Button
121+
variant="ghost"
122+
size="icon"
123+
asChild
124+
className="h-8 w-8 hidden md:inline-flex shrink-0"
125+
aria-label={t('sidebar.helpTooltip', {
126+
defaultValue: 'Help & Documentation',
127+
})}
128+
>
129+
<a
130+
href="https://docs.objectstack.ai"
131+
target="_blank"
132+
rel="noopener noreferrer"
133+
>
134+
<HelpCircle className="h-4 w-4" />
135+
</a>
136+
</Button>
137+
138+
<Separator
139+
orientation="vertical"
140+
className="hidden sm:block mx-0.5 h-5"
141+
/>
142+
143+
{/* Theme + Locale */}
144+
<div className="hidden sm:flex shrink-0">
145+
<ModeToggle />
146+
</div>
147+
<div className="hidden sm:flex shrink-0">
148+
<LocaleSwitcher />
149+
</div>
150+
151+
<Separator
152+
orientation="vertical"
153+
className="hidden sm:block mx-0.5 h-5"
154+
/>
155+
156+
{/* Workspace switcher — self-sizing; collapses when it renders null */}
157+
<div className="hidden md:flex shrink-0 min-w-0 max-w-52">
158+
<WorkspaceSwitcher
159+
onWorkspaceChange={() => {
160+
// Reload so adapter / permissions re-initialize with the new
161+
// tenant context, matching the sidebar's prior behavior.
162+
window.location.href = `${window.location.origin}/console/home`;
163+
}}
164+
/>
165+
</div>
166+
167+
{/* User profile */}
168+
<DropdownMenu>
169+
<DropdownMenuTrigger asChild>
170+
<button
171+
className="flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
172+
data-testid="home-user-menu-trigger"
173+
>
174+
<Avatar className="h-8 w-8 rounded-lg">
175+
<AvatarImage
176+
src={user?.image ?? '/avatars/user.jpg'}
177+
alt={user?.name ?? 'User'}
178+
/>
179+
<AvatarFallback className="rounded-lg bg-primary text-primary-foreground text-xs">
180+
{getUserInitials(user)}
181+
</AvatarFallback>
182+
</Avatar>
183+
<ChevronsUpDown className="hidden md:block h-4 w-4 text-muted-foreground" />
184+
</button>
185+
</DropdownMenuTrigger>
186+
<DropdownMenuContent align="end" sideOffset={4} className="w-56">
187+
<DropdownMenuLabel className="p-0 font-normal">
188+
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
189+
<Avatar className="h-8 w-8 rounded-lg">
190+
<AvatarImage
191+
src={user?.image ?? '/avatars/user.jpg'}
192+
alt={user?.name ?? 'User'}
193+
/>
194+
<AvatarFallback className="rounded-lg bg-primary text-primary-foreground">
195+
{getUserInitials(user)}
196+
</AvatarFallback>
197+
</Avatar>
198+
<div className="grid flex-1 text-left text-sm leading-tight">
199+
<span className="truncate font-semibold">
200+
{user?.name ?? 'User'}
201+
</span>
202+
<span className="truncate text-xs text-muted-foreground">
203+
{user?.email ?? ''}
204+
</span>
205+
</div>
206+
</div>
207+
</DropdownMenuLabel>
208+
<DropdownMenuSeparator />
209+
<DropdownMenuGroup>
210+
<DropdownMenuItem
211+
onClick={() => navigate('/apps/setup/system/profile')}
212+
>
213+
<UserIcon className="mr-2 h-4 w-4" />
214+
{t('user.profile', { defaultValue: 'Profile' })}
215+
</DropdownMenuItem>
216+
<DropdownMenuItem onClick={() => navigate('/apps/setup')}>
217+
<Settings className="mr-2 h-4 w-4" />
218+
{t('sidebar.settings', { defaultValue: 'Settings' })}
219+
</DropdownMenuItem>
220+
</DropdownMenuGroup>
221+
<DropdownMenuSeparator />
222+
<DropdownMenuItem
223+
className="text-destructive focus:text-destructive"
224+
onClick={() => signOut()}
225+
>
226+
<LogOut className="mr-2 h-4 w-4" />
227+
{t('user.logout', { defaultValue: 'Log out' })}
228+
</DropdownMenuItem>
229+
</DropdownMenuContent>
230+
</DropdownMenu>
231+
</div>
232+
</div>
233+
</header>
234+
);
235+
}

e2e/helpers/auth.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ const REACT_READY_TIMEOUT = 30_000;
1010
* MSW auth handlers run in-memory, so each browser context starts
1111
* without a session. This helper creates a fresh user via the
1212
* register form so that subsequent navigation hits authenticated
13-
* routes (HomeLayout with sidebar, etc.).
13+
* routes.
14+
*
15+
* Note: The home page uses a top navigation bar (no left sidebar); the
16+
* `UnifiedSidebar` is only rendered inside `/apps/:appName/*` routes.
1417
*/
1518
export async function registerAndLogin(page: Page) {
1619
await page.goto(`${CONSOLE_BASE}/register`);

0 commit comments

Comments
 (0)