feat: sync side panel navigation with URL routes (#76) (#87)

Move page.tsx to [[...panel]] optional catch-all route so each panel
gets its own URL (e.g. /tasks, /agents, /settings). URL is the source
of truth — synced into Zustand via usePathname on every navigation.
Enables bookmarking, refresh persistence, deep-linking, and browser
back/forward.
This commit is contained in:
nyk 2026-03-03 15:08:59 +07:00 committed by GitHub
parent a6fb27392b
commit 6ce38b13dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 71 additions and 41 deletions

View File

@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { NavRail } from '@/components/layout/nav-rail'
import { HeaderBar } from '@/components/layout/header-bar'
import { LiveFeed } from '@/components/layout/live-feed'
@ -39,7 +40,15 @@ import { useMissionControl } from '@/store'
export default function Home() {
const { connect } = useWebSocket()
const { activeTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, liveFeedOpen, toggleLiveFeed } = useMissionControl()
const { activeTab, setActiveTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, liveFeedOpen, toggleLiveFeed } = useMissionControl()
// Sync URL → Zustand activeTab
const pathname = usePathname()
const panelFromUrl = pathname === '/' ? 'overview' : pathname.slice(1)
useEffect(() => {
setActiveTab(panelFromUrl)
}, [panelFromUrl, setActiveTab])
// Connect to SSE for real-time local DB events (tasks, agents, chat, etc.)
useServerEvents()

View File

@ -2,6 +2,7 @@
import { useState, useCallback } from 'react'
import { useMissionControl } from '@/store'
import { useNavigateToPanel } from '@/lib/navigation'
import { useSmartPoll } from '@/lib/use-smart-poll'
interface DbStats {
@ -35,8 +36,8 @@ export function Dashboard() {
logs,
agents,
tasks,
setActiveTab,
} = useMissionControl()
const navigateToPanel = useNavigateToPanel()
const isLocal = dashboardMode === 'local'
const subscriptionLabel = subscription?.type
? subscription.type.charAt(0).toUpperCase() + subscription.type.slice(1)
@ -124,7 +125,7 @@ export function Dashboard() {
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{isLocal ? (
<>
<div className="cursor-pointer" onClick={() => setActiveTab('sessions')}>
<div className="cursor-pointer" onClick={() => navigateToPanel('sessions')}>
<MetricCard
label="Active Sessions"
value={claudeStats?.active_sessions ?? activeSessions}
@ -133,7 +134,7 @@ export function Dashboard() {
color="blue"
/>
</div>
<div className="cursor-pointer" onClick={() => setActiveTab('sessions')}>
<div className="cursor-pointer" onClick={() => navigateToPanel('sessions')}>
<MetricCard
label="Projects"
value={claudeStats?.unique_projects ?? 0}
@ -141,7 +142,7 @@ export function Dashboard() {
color="green"
/>
</div>
<div className="cursor-pointer" onClick={() => setActiveTab('tokens')}>
<div className="cursor-pointer" onClick={() => navigateToPanel('tokens')}>
<MetricCard
label="Tokens Used"
value={formatTokensShort((claudeStats?.total_input_tokens ?? 0) + (claudeStats?.total_output_tokens ?? 0))}
@ -150,7 +151,7 @@ export function Dashboard() {
color="purple"
/>
</div>
<div className="cursor-pointer" onClick={() => setActiveTab('tokens')}>
<div className="cursor-pointer" onClick={() => navigateToPanel('tokens')}>
<MetricCard
label="Est. Cost"
value={subscriptionLabel ? `Included` : `$${(claudeStats?.total_estimated_cost ?? 0).toFixed(2)}`}
@ -162,7 +163,7 @@ export function Dashboard() {
</>
) : (
<>
<div className="cursor-pointer" onClick={() => setActiveTab('history')}>
<div className="cursor-pointer" onClick={() => navigateToPanel('history')}>
<MetricCard
label="Active Sessions"
value={activeSessions}
@ -171,7 +172,7 @@ export function Dashboard() {
color="blue"
/>
</div>
<div className="cursor-pointer" onClick={() => setActiveTab('agents')}>
<div className="cursor-pointer" onClick={() => navigateToPanel('agents')}>
<MetricCard
label="Agents Online"
value={onlineAgents}
@ -180,7 +181,7 @@ export function Dashboard() {
color="green"
/>
</div>
<div className="cursor-pointer" onClick={() => setActiveTab('tasks')}>
<div className="cursor-pointer" onClick={() => navigateToPanel('tasks')}>
<MetricCard
label="Tasks Running"
value={runningTasks}
@ -189,7 +190,7 @@ export function Dashboard() {
color="purple"
/>
</div>
<div className="cursor-pointer" onClick={() => setActiveTab('logs')}>
<div className="cursor-pointer" onClick={() => navigateToPanel('logs')}>
<MetricCard
label="Errors (24h)"
value={errorCount}
@ -261,7 +262,7 @@ export function Dashboard() {
{/* Middle panel: Claude Stats (local) or Security & Audit (full) */}
{isLocal ? (
<div className="panel cursor-pointer hover:border-primary/30 transition-smooth" onClick={() => setActiveTab('sessions')}>
<div className="panel cursor-pointer hover:border-primary/30 transition-smooth" onClick={() => navigateToPanel('sessions')}>
<div className="panel-header">
<h3 className="text-sm font-semibold text-foreground">Claude Code Stats</h3>
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-2xs font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20">
@ -305,7 +306,7 @@ export function Dashboard() {
</div>
</div>
) : (
<div className="panel cursor-pointer hover:border-primary/30 transition-smooth" onClick={() => setActiveTab('audit')}>
<div className="panel cursor-pointer hover:border-primary/30 transition-smooth" onClick={() => navigateToPanel('audit')}>
<div className="panel-header">
<h3 className="text-sm font-semibold text-foreground">Security & Audit</h3>
{dbStats && dbStats.audit.loginFailures > 0 && (
@ -513,15 +514,15 @@ export function Dashboard() {
{/* Quick Actions */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-2">
{!isLocal && (
<QuickAction label="Spawn Agent" desc="Launch sub-agent" tab="spawn" icon={<SpawnActionIcon />} setActiveTab={setActiveTab} />
<QuickAction label="Spawn Agent" desc="Launch sub-agent" tab="spawn" icon={<SpawnActionIcon />} onNavigate={navigateToPanel} />
)}
<QuickAction label="View Logs" desc="Real-time viewer" tab="logs" icon={<LogActionIcon />} setActiveTab={setActiveTab} />
<QuickAction label="Task Board" desc="Kanban view" tab="tasks" icon={<TaskActionIcon />} setActiveTab={setActiveTab} />
<QuickAction label="Memory" desc="Knowledge base" tab="memory" icon={<MemoryActionIcon />} setActiveTab={setActiveTab} />
<QuickAction label="View Logs" desc="Real-time viewer" tab="logs" icon={<LogActionIcon />} onNavigate={navigateToPanel} />
<QuickAction label="Task Board" desc="Kanban view" tab="tasks" icon={<TaskActionIcon />} onNavigate={navigateToPanel} />
<QuickAction label="Memory" desc="Knowledge base" tab="memory" icon={<MemoryActionIcon />} onNavigate={navigateToPanel} />
{isLocal ? (
<QuickAction label="Sessions" desc="Claude Code sessions" tab="sessions" icon={<SessionIcon />} setActiveTab={setActiveTab} />
<QuickAction label="Sessions" desc="Claude Code sessions" tab="sessions" icon={<SessionIcon />} onNavigate={navigateToPanel} />
) : (
<QuickAction label="Orchestration" desc="Workflows & pipelines" tab="orchestration" icon={<PipelineActionIcon />} setActiveTab={setActiveTab} />
<QuickAction label="Orchestration" desc="Workflows & pipelines" tab="orchestration" icon={<PipelineActionIcon />} onNavigate={navigateToPanel} />
)}
</div>
</div>
@ -616,13 +617,13 @@ function StatusBadge({ connected }: { connected: boolean }) {
)
}
function QuickAction({ label, desc, tab, icon, setActiveTab }: {
function QuickAction({ label, desc, tab, icon, onNavigate }: {
label: string; desc: string; tab: string; icon: React.ReactNode
setActiveTab: (tab: string) => void
onNavigate: (tab: string) => void
}) {
return (
<button
onClick={() => setActiveTab(tab)}
onClick={() => onNavigate(tab)}
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:border-primary/30 hover:bg-primary/5 transition-smooth text-left group"
>
<div className="w-8 h-8 rounded-md bg-secondary flex items-center justify-center shrink-0 group-hover:bg-primary/10 transition-smooth">

View File

@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'
import { useMissionControl } from '@/store'
import { useNavigateToPanel } from '@/lib/navigation'
interface MenuItem {
id: string
@ -26,7 +27,8 @@ const menuItems: MenuItem[] = [
]
export function Sidebar() {
const { activeTab, setActiveTab, connection, sessions } = useMissionControl()
const { activeTab, connection, sessions } = useMissionControl()
const navigateToPanel = useNavigateToPanel()
const [systemStats, setSystemStats] = useState<any>(null)
useEffect(() => {
@ -61,7 +63,7 @@ export function Sidebar() {
{menuItems.map((item) => (
<li key={item.id}>
<button
onClick={() => setActiveTab(item.id)}
onClick={() => navigateToPanel(item.id)}
className={`w-full flex items-start space-x-3 px-3 py-3 rounded-lg text-left transition-colors group ${
activeTab === item.id
? 'bg-primary text-primary-foreground shadow-sm'

View File

@ -4,6 +4,7 @@ import { useState, useRef, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { useMissionControl } from '@/store'
import { useWebSocket } from '@/lib/websocket'
import { useNavigateToPanel } from '@/lib/navigation'
import { ThemeToggle } from '@/components/ui/theme-toggle'
import { DigitalClock } from '@/components/ui/digital-clock'
@ -17,8 +18,9 @@ interface SearchResult {
}
export function HeaderBar() {
const { activeTab, setActiveTab, connection, sessions, chatPanelOpen, setChatPanelOpen, notifications, unreadNotificationCount, currentUser, setCurrentUser } = useMissionControl()
const { activeTab, connection, sessions, chatPanelOpen, setChatPanelOpen, notifications, unreadNotificationCount, currentUser, setCurrentUser } = useMissionControl()
const { isConnected, reconnect } = useWebSocket()
const navigateToPanel = useNavigateToPanel()
const activeSessions = sessions.filter(s => s.active).length
const tabLabels: Record<string, string> = {
@ -105,7 +107,7 @@ export function HeaderBar() {
audit: 'audit', message: 'agents', notification: 'notifications',
webhook: 'webhooks', pipeline: 'agents', alert_rule: 'alerts',
}
setActiveTab(typeToTab[result.type] || 'overview')
navigateToPanel(typeToTab[result.type] || 'overview')
setSearchOpen(false)
setSearchQuery('')
setSearchResults([])
@ -187,10 +189,7 @@ export function HeaderBar() {
{/* Notifications */}
<button
onClick={() => {
const { setActiveTab } = useMissionControl.getState()
setActiveTab('notifications')
}}
onClick={() => navigateToPanel('notifications')}
className="h-8 w-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center relative"
>
<BellIcon />

View File

@ -1,9 +1,11 @@
'use client'
import { useMissionControl } from '@/store'
import { useNavigateToPanel } from '@/lib/navigation'
export function LocalModeBanner() {
const { dashboardMode, bannerDismissed, dismissBanner, setActiveTab } = useMissionControl()
const { dashboardMode, bannerDismissed, dismissBanner } = useMissionControl()
const navigateToPanel = useNavigateToPanel()
if (dashboardMode === 'full' || bannerDismissed) return null
@ -15,7 +17,7 @@ export function LocalModeBanner() {
{' — running in Local Mode. Monitoring Claude Code sessions, tasks, and local data.'}
</p>
<button
onClick={() => setActiveTab('gateways')}
onClick={() => navigateToPanel('gateways')}
className="shrink-0 text-2xs font-medium text-blue-400 hover:text-blue-300 px-2 py-1 rounded border border-blue-500/20 hover:border-blue-500/40 transition-colors"
>
Configure Gateway

View File

@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import { useMissionControl } from '@/store'
import { useNavigateToPanel } from '@/lib/navigation'
interface NavItem {
id: string
@ -69,7 +70,8 @@ const navGroups: NavGroup[] = [
const allNavItems = navGroups.flatMap(g => g.items)
export function NavRail() {
const { activeTab, setActiveTab, connection, dashboardMode, sidebarExpanded, collapsedGroups, toggleSidebar, toggleGroup } = useMissionControl()
const { activeTab, connection, dashboardMode, sidebarExpanded, collapsedGroups, toggleSidebar, toggleGroup } = useMissionControl()
const navigateToPanel = useNavigateToPanel()
const isLocal = dashboardMode === 'local'
// Keyboard shortcut: [ to toggle sidebar
@ -167,7 +169,7 @@ export function NavRail() {
active={activeTab === item.id}
expanded={sidebarExpanded}
disabled={disabled}
onClick={() => { if (!disabled) setActiveTab(item.id) }}
onClick={() => { if (!disabled) navigateToPanel(item.id) }}
/>
)
})}
@ -196,7 +198,7 @@ export function NavRail() {
</nav>
{/* Mobile: Bottom tab bar */}
<MobileBottomBar activeTab={activeTab} setActiveTab={setActiveTab} />
<MobileBottomBar activeTab={activeTab} navigateToPanel={navigateToPanel} />
</>
)
}
@ -257,9 +259,9 @@ function NavButton({ item, active, expanded, disabled, onClick }: {
)
}
function MobileBottomBar({ activeTab, setActiveTab }: {
function MobileBottomBar({ activeTab, navigateToPanel }: {
activeTab: string
setActiveTab: (tab: string) => void
navigateToPanel: (tab: string) => void
}) {
const [sheetOpen, setSheetOpen] = useState(false)
const priorityItems = allNavItems.filter(i => i.priority)
@ -273,7 +275,7 @@ function MobileBottomBar({ activeTab, setActiveTab }: {
{priorityItems.map((item) => (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
onClick={() => navigateToPanel(item.id)}
className={`flex flex-col items-center justify-center gap-0.5 px-2 py-2 rounded-lg transition-smooth min-w-[48px] min-h-[48px] ${
activeTab === item.id
? 'text-primary'
@ -311,17 +313,17 @@ function MobileBottomBar({ activeTab, setActiveTab }: {
open={sheetOpen}
onClose={() => setSheetOpen(false)}
activeTab={activeTab}
setActiveTab={setActiveTab}
navigateToPanel={navigateToPanel}
/>
</>
)
}
function MobileBottomSheet({ open, onClose, activeTab, setActiveTab }: {
function MobileBottomSheet({ open, onClose, activeTab, navigateToPanel }: {
open: boolean
onClose: () => void
activeTab: string
setActiveTab: (tab: string) => void
navigateToPanel: (tab: string) => void
}) {
// Track mount state for animation
const [visible, setVisible] = useState(false)
@ -385,7 +387,7 @@ function MobileBottomSheet({ open, onClose, activeTab, setActiveTab }: {
<button
key={item.id}
onClick={() => {
setActiveTab(item.id)
navigateToPanel(item.id)
handleClose()
}}
className={`flex items-center gap-2.5 px-3 min-h-[48px] rounded-xl transition-smooth ${

15
src/lib/navigation.ts Normal file
View File

@ -0,0 +1,15 @@
'use client'
import { useRouter } from 'next/navigation'
import { useCallback } from 'react'
export function panelHref(panel: string): string {
return panel === 'overview' ? '/' : `/${panel}`
}
export function useNavigateToPanel() {
const router = useRouter()
return useCallback((panel: string) => {
router.push(panelHref(panel))
}, [router])
}