refactor(desktop): redesign layout with sidebar navigation

- Replace tab navigation with collapsible sidebar
- Fix header scroll issue with proper flex container structure
- Add TooltipProvider at app root
- Simplify onboarding store by removing forceOnboarding flag

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-11 18:51:14 +08:00
parent bbf2a16f39
commit bbf91a98ca
3 changed files with 90 additions and 75 deletions

View file

@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { createHashRouter, Navigate, RouterProvider } from 'react-router-dom'
import { ThemeProvider } from './components/theme-provider'
import { TooltipProvider } from '@multica/ui/components/ui/tooltip'
import Layout from './pages/layout'
import HomePage from './pages/home'
import ChatPage from './pages/chat'
@ -15,8 +16,7 @@ import { useChannelsStore } from './stores/channels'
function OnboardingGuard({ children }: { children: React.ReactNode }) {
const completed = useOnboardingStore((s) => s.completed)
const forceOnboarding = useOnboardingStore((s) => s.forceOnboarding)
if (!completed || forceOnboarding) return <Navigate to="/onboarding" replace />
if (!completed) return <Navigate to="/onboarding" replace />
return <>{children}</>
}
@ -48,7 +48,6 @@ const router = createHashRouter([
export default function App() {
useEffect(() => {
useOnboardingStore.getState().initForceFlag()
// Prefetch global data at app startup
useProviderStore.getState().fetch()
useChannelsStore.getState().fetch()
@ -56,7 +55,9 @@ export default function App() {
return (
<ThemeProvider defaultTheme="system" storageKey="multica-theme">
<RouterProvider router={router} />
<TooltipProvider>
<RouterProvider router={router} />
</TooltipProvider>
</ThemeProvider>
)
}

View file

@ -1,90 +1,114 @@
import { Outlet, NavLink, useLocation } from 'react-router-dom'
import { Toaster } from '@multica/ui/components/ui/sonner'
import { Button } from '@multica/ui/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react'
import {
Settings02Icon,
Home01Icon,
Comment01Icon,
CodeIcon,
PlugIcon,
Comment01Icon,
Share08Icon,
Time04Icon,
} from '@hugeicons/core-free-icons'
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from '@multica/ui/components/ui/sidebar'
import { cn } from '@multica/ui/lib/utils'
import { ModeToggle } from '../components/mode-toggle'
import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
import ChatPage from './chat'
const tabs = [
{ path: '/', label: 'Home', icon: Home01Icon, exact: true },
const navItems = [
{ path: '/', label: 'Home', icon: Home01Icon },
{ path: '/chat', label: 'Chat', icon: Comment01Icon },
{ path: '/tools', label: 'Tools', icon: CodeIcon },
{ path: '/skills', label: 'Skills', icon: PlugIcon },
{ path: '/channels', label: 'Channels', icon: Share08Icon },
{ path: '/crons', label: 'Cron', icon: Time04Icon },
{ path: '/crons', label: 'Crons', icon: Time04Icon },
]
function MainHeader() {
const { state, isMobile } = useSidebar()
const needsTrafficLightSpace = state === 'collapsed' || isMobile
return (
<header className="h-12 shrink-0 flex items-center px-4">
{/* Drag placeholder for traffic lights when sidebar is collapsed */}
<div
className={cn(
'h-full shrink-0 transition-[width] duration-200 ease-linear',
needsTrafficLightSpace ? 'w-16' : 'w-0'
)}
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
/>
<SidebarTrigger />
{/* Spacer */}
<div className="flex-1" />
{/* Right: Theme toggle */}
<ModeToggle />
</header>
)
}
export default function Layout() {
const location = useLocation()
return (
<div className="h-dvh flex flex-col bg-background">
{/* Header with drag region for macOS */}
<header
className="flex items-center justify-between px-4 py-3 border-b pl-20"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">Multica</span>
</div>
<div
className="flex items-center gap-1"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<Button variant="ghost" size="icon">
<HugeiconsIcon icon={Settings02Icon} className="size-5" />
</Button>
</div>
</header>
<div className="flex h-screen flex-col bg-background text-foreground">
<SidebarProvider className="flex-1 overflow-hidden">
<Sidebar>
{/* Traffic light area */}
<SidebarHeader
className="h-12 shrink-0"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
/>
{/* Tabs */}
<nav className="flex gap-1 px-4 py-2 border-b">
{tabs.map((tab) => {
const isActive = tab.exact
? location.pathname === tab.path
: location.pathname.startsWith(tab.path)
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
{navItems.map((item) => {
const isActive = item.path === '/'
? location.pathname === '/'
: location.pathname.startsWith(item.path)
return (
<SidebarMenuItem key={item.path}>
<NavLink to={item.path}>
<SidebarMenuButton isActive={isActive}>
<HugeiconsIcon icon={item.icon} className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</NavLink>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
</Sidebar>
return (
<NavLink key={tab.path} to={tab.path}>
<Button
variant={isActive ? 'secondary' : 'ghost'}
size="sm"
className={cn('gap-2', isActive && 'bg-secondary')}
>
<HugeiconsIcon icon={tab.icon} className="size-4" />
{tab.label}
</Button>
</NavLink>
)
})}
</nav>
<SidebarInset className="overflow-hidden">
<MainHeader />
{/* Content */}
<main className="flex-1 overflow-auto relative">
{/* ChatPage is always mounted (cached), hidden via CSS */}
<div className={cn('absolute inset-0', location.pathname === '/chat' ? '' : 'hidden')}>
<ChatPage />
</div>
{/* Main Content */}
<main className="flex-1 overflow-hidden min-h-1">
<Outlet />
</main>
</SidebarInset>
{/* Other routes render normally via Outlet */}
{location.pathname !== '/chat' && (
<div className="p-4 h-full">
<Outlet />
</div>
)}
</main>
<Toaster />
<DeviceConfirmDialog />
<Toaster />
<DeviceConfirmDialog />
</SidebarProvider>
</div>
)
}

View file

@ -10,7 +10,6 @@ interface AcknowledgementsState {
interface OnboardingStore {
completed: boolean
forceOnboarding: boolean
currentStep: number
acknowledgements: AcknowledgementsState
allAcknowledged: boolean
@ -23,14 +22,12 @@ interface OnboardingStore {
setProviderConfigured: (configured: boolean) => void
setClientConnected: (connected: boolean) => void
completeOnboarding: () => void
initForceFlag: () => Promise<void>
}
export const useOnboardingStore = create<OnboardingStore>()(
persist(
(set, get) => ({
completed: false,
forceOnboarding: false,
currentStep: 0,
acknowledgements: {
@ -57,14 +54,7 @@ export const useOnboardingStore = create<OnboardingStore>()(
setClientConnected: (connected) => set({ clientConnected: connected }),
completeOnboarding: () => set({ completed: true, forceOnboarding: false, currentStep: 0 }),
initForceFlag: async () => {
const flags = await window.electronAPI.app.getFlags()
if (flags.forceOnboarding) {
set({ forceOnboarding: true })
}
},
completeOnboarding: () => set({ completed: true, currentStep: 0 }),
}),
{
name: 'multica-onboarding',