chore: remove pre-branch-compare backup files
All checks were successful
Stuffle/nebula-os/pipeline/head This commit was not built
All checks were successful
Stuffle/nebula-os/pipeline/head This commit was not built
This commit is contained in:
@@ -1,183 +0,0 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate, useSearchParams } from 'react-router-dom'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { ProtectedRoute } from '@/auth/ProtectedRoute'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { ToastProvider } from '@/components/Toast'
|
||||
import { ModeGuard } from '@/components/ModeGuard'
|
||||
import { GoogleOneTapPopup } from '@/components/GoogleOneTapPopup'
|
||||
import { InstallableRoute } from '@/components/InstallableRoute'
|
||||
|
||||
// ── Eagerly loaded (needed before auth) ──────────────────────────────────────
|
||||
import { Landing } from '@/pages/Landing'
|
||||
import { Login } from '@/pages/Login'
|
||||
import { AuthCallback } from '@/pages/AuthCallback'
|
||||
import { Stub } from '@/pages/Stub'
|
||||
import { Terms } from '@/pages/Terms'
|
||||
import { Privacy } from '@/pages/Privacy'
|
||||
|
||||
function AgentsNewRedirect() {
|
||||
const [params] = useSearchParams()
|
||||
const skill = params.get('skill') ?? params.get('name') ?? ''
|
||||
const dest = `/agents?spawn=1${skill ? `&skill=${encodeURIComponent(skill)}` : ''}`
|
||||
return <Navigate to={dest} replace />
|
||||
}
|
||||
|
||||
function HomeRoute() {
|
||||
const { isLoading, isAuthenticated } = useAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return <PageFallback />
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/chat" replace />
|
||||
}
|
||||
|
||||
return <Landing />
|
||||
}
|
||||
|
||||
function EmptyRoute() {
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Route-level lazy chunks — each page loads only when navigated to ─────────
|
||||
const ControlPlane = lazy(() => import('@/pages/control-plane').then(m => ({ default: m.ControlPlane })))
|
||||
const AgentsPage = lazy(() => import('@/pages/agents').then(m => ({ default: m.AgentsPage })))
|
||||
const AgentDetailPage = lazy(() => import('@/pages/agents').then(m => ({ default: m.AgentDetailPage })))
|
||||
const AppsPage = lazy(() => import('@/pages/apps').then(m => ({ default: m.AppsPage })))
|
||||
const Tasks = lazy(() => import('@/pages/tasks').then(m => ({ default: m.Tasks })))
|
||||
const TaskDebuggerPage = lazy(() => import('@/pages/task-debugger').then(m => ({ default: m.TaskDebuggerPage })))
|
||||
const Plugins = lazy(() => import('@/pages/plugins').then(m => ({ default: m.Plugins })))
|
||||
const PluginDetail = lazy(() => import('@/pages/plugins').then(m => ({ default: m.PluginDetail })))
|
||||
const Policies = lazy(() => import('@/pages/policies').then(m => ({ default: m.Policies })))
|
||||
const PolicyDetail = lazy(() => import('@/pages/policies').then(m => ({ default: m.PolicyDetail })))
|
||||
const Security = lazy(() => import('@/pages/security').then(m => ({ default: m.Security })))
|
||||
const SecurityCenter = lazy(() => import('@/pages/security-center').then(m => ({ default: m.SecurityCenter })))
|
||||
const VulnerabilityDetail = lazy(() => import('@/pages/security-center').then(m => ({ default: m.VulnerabilityDetail })))
|
||||
const Logs = lazy(() => import('@/pages/logs').then(m => ({ default: m.Logs })))
|
||||
const Settings = lazy(() => import('@/pages/settings').then(m => ({ default: m.Settings })))
|
||||
const Integrations = lazy(() => import('@/pages/integrations').then(m => ({ default: m.Integrations })))
|
||||
const IntegrationCatalogDetail = lazy(() => import('@/pages/integrations').then(m => ({ default: m.IntegrationCatalogDetail })))
|
||||
const IntegrationInstanceDetail = lazy(() => import('@/pages/integrations').then(m => ({ default: m.IntegrationInstanceDetail })))
|
||||
const SchedulesPage = lazy(() => import('@/pages/schedules').then(m => ({ default: m.SchedulesPage })))
|
||||
const ScheduleDetailPage = lazy(() => import('@/pages/schedules').then(m => ({ default: m.ScheduleDetailPage })))
|
||||
const Models = lazy(() => import('@/pages/models').then(m => ({ default: m.Models })))
|
||||
const ContextBank = lazy(() => import('@/pages/context-bank').then(m => ({ default: m.ContextBank })))
|
||||
const Approvals = lazy(() => import('@/pages/approvals').then(m => ({ default: m.Approvals })))
|
||||
const KnowledgeBase = lazy(() => import('@/pages/knowledge-base').then(m => ({ default: m.KnowledgeBase })))
|
||||
const Workflows = lazy(() => import('@/pages/workflows').then(m => ({ default: m.Workflows })))
|
||||
const WorkflowDetail = lazy(() => import('@/pages/workflows').then(m => ({ default: m.WorkflowDetail })))
|
||||
const Marketplace = lazy(() => import('@/pages/marketplace').then(m => ({ default: m.Marketplace })))
|
||||
const MarketplaceItemDetail = lazy(() => import('@/pages/marketplace').then(m => ({ default: m.MarketplaceItemDetail })))
|
||||
const Docs = lazy(() => import('@/pages/Docs').then(m => ({ default: m.Docs })))
|
||||
const BlogList = lazy(() => import('@/pages/blog').then(m => ({ default: m.BlogList })))
|
||||
const BlogPost = lazy(() => import('@/pages/blog').then(m => ({ default: m.BlogPost })))
|
||||
const BlogNew = lazy(() => import('@/pages/blog').then(m => ({ default: m.BlogNew })))
|
||||
const BlogEdit = lazy(() => import('@/pages/blog').then(m => ({ default: m.BlogEdit })))
|
||||
const QnAList = lazy(() => import('@/pages/qna').then(m => ({ default: m.QnAList })))
|
||||
const QnAPost = lazy(() => import('@/pages/qna').then(m => ({ default: m.QnAPost })))
|
||||
const QnANew = lazy(() => import('@/pages/qna').then(m => ({ default: m.QnANew })))
|
||||
const QnAEdit = lazy(() => import('@/pages/qna').then(m => ({ default: m.QnAEdit })))
|
||||
const BuildDashboard = lazy(() => import('@/pages/build-dashboard').then(m => ({ default: m.BuildDashboard })))
|
||||
const Analytics = lazy(() => import('@/pages/analytics').then(m => ({ default: m.Analytics })))
|
||||
const Profile = lazy(() => import('@/pages/Profile'))
|
||||
const UpgradeWall = lazy(() => import('@/pages/quota/UpgradeWall').then(m => ({ default: m.UpgradeWall })))
|
||||
const GetMoreRuns = lazy(() => import('@/pages/quota/GetMoreRuns').then(m => ({ default: m.GetMoreRuns })))
|
||||
const AdminQuota = lazy(() => import('@/pages/quota/AdminQuota').then(m => ({ default: m.AdminQuota })))
|
||||
|
||||
// ── Minimal spinner shown during chunk fetch ──────────────────────────────────
|
||||
function PageFallback() {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', minHeight: 200 }}>
|
||||
<span className="ne-spinner" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<BrowserRouter>
|
||||
<GoogleOneTapPopup />
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<Routes>
|
||||
{/* Auth routes — no layout, no protection */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/callback" element={<AuthCallback />} />
|
||||
|
||||
<Route path="/" element={<HomeRoute />} />
|
||||
|
||||
{/* Public content routes — readable without login */}
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/docs" element={<Docs />} />
|
||||
<Route path="/terms" element={<Terms />} />
|
||||
<Route path="/privacy" element={<Privacy />} />
|
||||
<Route path="/blog" element={<BlogList />} />
|
||||
<Route path="/blog/:postId" element={<BlogPost />} />
|
||||
<Route path="/blogs" element={<BlogList />} />
|
||||
<Route path="/blogs/:postId" element={<BlogPost />} />
|
||||
</Route>
|
||||
|
||||
{/* All app routes share the Navbar layout + require auth */}
|
||||
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||
<Route path="/chat" element={<EmptyRoute />} />
|
||||
<Route path="/control" element={<ControlPlane />} />
|
||||
<Route path="/apps" element={<AppsPage />} />
|
||||
<Route path="/surfaces" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="/terminal" element={<Navigate to="/chat" replace />} />
|
||||
<Route path="/agents" element={<InstallableRoute slug="agents"><AgentsPage /></InstallableRoute>} />
|
||||
<Route path="/agents/new" element={<AgentsNewRedirect />} />
|
||||
<Route path="/agents/:id" element={<InstallableRoute slug="agents"><AgentDetailPage /></InstallableRoute>} />
|
||||
<Route path="/tasks" element={<InstallableRoute slug="tasks"><Tasks /></InstallableRoute>} />
|
||||
<Route path="/tasks/:id" element={<InstallableRoute slug="tasks"><TaskDebuggerPage /></InstallableRoute>} />
|
||||
<Route path="/plugins" element={<InstallableRoute slug="plugins"><ModeGuard allowedModes={['advanced']}><Plugins /></ModeGuard></InstallableRoute>} />
|
||||
<Route path="/plugins/:id" element={<InstallableRoute slug="plugins"><ModeGuard allowedModes={['advanced']}><PluginDetail /></ModeGuard></InstallableRoute>} />
|
||||
<Route path="/policies" element={<ModeGuard allowedModes={['advanced']}><Policies /></ModeGuard>} />
|
||||
<Route path="/policies/:id" element={<ModeGuard allowedModes={['advanced']}><PolicyDetail /></ModeGuard>} />
|
||||
<Route path="/security" element={<ModeGuard allowedModes={['advanced']}><Security /></ModeGuard>} />
|
||||
<Route path="/security-center" element={<ModeGuard allowedModes={['advanced']}><SecurityCenter /></ModeGuard>} />
|
||||
<Route path="/security-center/:id" element={<ModeGuard allowedModes={['advanced']}><VulnerabilityDetail /></ModeGuard>} />
|
||||
<Route path="/logs" element={<ModeGuard allowedModes={['advanced']}><Logs /></ModeGuard>} />
|
||||
<Route path="/memory" element={
|
||||
<Stub title="Agent Memory" description="Working, episodic, semantic and skill memory. Consolidation engine. Coming soon." />
|
||||
} />
|
||||
<Route path="/schedules" element={<SchedulesPage />} />
|
||||
<Route path="/schedules/:id" element={<ScheduleDetailPage />} />
|
||||
<Route path="/integrations" element={<Integrations />} />
|
||||
<Route path="/integrations/catalog/:slug" element={<IntegrationCatalogDetail />} />
|
||||
<Route path="/integrations/:id" element={<IntegrationInstanceDetail />} />
|
||||
<Route path="/models" element={<Models />} />
|
||||
<Route path="/context-bank" element={<ContextBank />} />
|
||||
<Route path="/debugger" element={
|
||||
<Stub title="Debugger" description="Step-through agent execution, inspect reasoning chains, tool calls, memory reads, and policy decisions in real time." />
|
||||
} />
|
||||
<Route path="/marketplace" element={<InstallableRoute slug="marketplace"><Marketplace /></InstallableRoute>} />
|
||||
<Route path="/marketplace/:id" element={<InstallableRoute slug="marketplace"><MarketplaceItemDetail /></InstallableRoute>} />
|
||||
<Route path="/approvals" element={<Approvals />} />
|
||||
<Route path="/knowledge" element={<KnowledgeBase />} />
|
||||
<Route path="/workflows" element={<InstallableRoute slug="workflows"><Workflows /></InstallableRoute>} />
|
||||
<Route path="/workflows/:id" element={<InstallableRoute slug="workflows"><WorkflowDetail /></InstallableRoute>} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/blog/new" element={<BlogNew />} />
|
||||
<Route path="/blog/:postId/edit" element={<BlogEdit />} />
|
||||
<Route path="/qna" element={<QnAList />} />
|
||||
<Route path="/qna/new" element={<QnANew />} />
|
||||
<Route path="/qna/:postId/edit" element={<QnAEdit />} />
|
||||
<Route path="/qna/:postId" element={<QnAPost />} />
|
||||
<Route path="/build" element={<BuildDashboard />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/quota" element={<GetMoreRuns />} />
|
||||
<Route path="/quota/upgrade" element={<UpgradeWall />} />
|
||||
<Route path="/admin/quota" element={<AdminQuota />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,484 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTheme } from '@/theme'
|
||||
import { useProfile } from '@/hooks/useProfile'
|
||||
import { NotificationsPanel } from './NotificationsPanel'
|
||||
import { useShell } from '@/context/ShellContext'
|
||||
import {
|
||||
Bot,
|
||||
ListTodo,
|
||||
Settings,
|
||||
Sun,
|
||||
Moon,
|
||||
BookOpen,
|
||||
Library,
|
||||
MessagesSquare,
|
||||
Menu,
|
||||
X,
|
||||
GitBranch,
|
||||
LayoutGrid,
|
||||
Bell,
|
||||
User,
|
||||
LogOut,
|
||||
Search,
|
||||
TerminalSquare,
|
||||
MoreHorizontal,
|
||||
} from 'lucide-react'
|
||||
import { ChatAiIcon } from '@/components/icons/ChatAiIcon'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { iam } from '@/auth/iam'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { installablesApi, navApi, notificationsApi } from '@/api/client'
|
||||
import { useNotificationsWS } from '@/hooks/useNotificationsWS'
|
||||
import { QuotaStatusWidget } from '@/components/QuotaStatusWidget'
|
||||
import { useNetworkStatus } from '@/hooks/useNetworkStatus'
|
||||
import { SystemStatusDot, SystemStatusModal, NetworkAlertBanner } from '@/components/SystemStatusDot'
|
||||
import { DevSimulator } from '@/components/DevSimulator'
|
||||
|
||||
const PRIMARY_NAV = [
|
||||
{ path: '/agents', label: 'Agents', icon: Bot, countKey: 'agents' as const },
|
||||
{ path: '/tasks', label: 'Tasks', icon: ListTodo, countKey: 'tasks' as const },
|
||||
{ path: '/workflows', label: 'Workflows', icon: GitBranch, countKey: 'workflows' as const },
|
||||
]
|
||||
|
||||
type EntityCounts = { agents: number; tasks: number; workflows: number; plugins: number; policies: number }
|
||||
type LiveStats = { agents: number; tasks: number; workflows: number; plugins: number }
|
||||
|
||||
function useNavCounts(enabled: boolean) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['nav-counts'],
|
||||
queryFn: () => navApi.counts(),
|
||||
staleTime: 60_000,
|
||||
enabled,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
type Popover = 'notif' | 'profile' | 'more' | null
|
||||
|
||||
export function Navbar() {
|
||||
const { pathname } = useLocation()
|
||||
const { theme, toggleWithTransition } = useTheme()
|
||||
const { terminalVisible, terminalCollapsed, setTerminalVisible, setTerminalCollapsed } = useShell()
|
||||
const { profile } = useProfile()
|
||||
const { isAuthenticated, logout } = useAuth()
|
||||
const [activePopover, setActivePopover] = useState<Popover>(null)
|
||||
const notifRef = useRef<HTMLDivElement>(null)
|
||||
const profileRef = useRef<HTMLDivElement>(null)
|
||||
const moreRef = useRef<HTMLDivElement>(null)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const networkStatus = useNetworkStatus()
|
||||
const [statusModalOpen, setStatusModalOpen] = useState(false)
|
||||
const [isMobile, setIsMobile] = useState(() => window.matchMedia('(max-width: 640px)').matches)
|
||||
// Global real-time channel: notifications + approvals.
|
||||
useNotificationsWS(isAuthenticated)
|
||||
const navCounts = useNavCounts(isAuthenticated)
|
||||
const { data: installables = [] } = useQuery({
|
||||
queryKey: ['installables'],
|
||||
queryFn: () => installablesApi.list(),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const counts: EntityCounts = {
|
||||
agents: navCounts?.agents ?? 0,
|
||||
tasks: navCounts?.tasks ?? 0,
|
||||
workflows: navCounts?.workflows ?? 0,
|
||||
plugins: navCounts?.plugins ?? 0,
|
||||
policies: navCounts?.policies ?? 0,
|
||||
}
|
||||
const liveStats: LiveStats = {
|
||||
agents: navCounts?.agents_running ?? 0,
|
||||
tasks: navCounts?.tasks_running ?? 0,
|
||||
workflows: navCounts?.workflows_active ?? 0,
|
||||
plugins: navCounts?.plugins ?? 0,
|
||||
}
|
||||
const { data: unreadData } = useQuery({
|
||||
queryKey: ['notifications-unread'],
|
||||
queryFn: () => notificationsApi.unreadCount(),
|
||||
enabled: isAuthenticated,
|
||||
})
|
||||
const unreadCount = unreadData?.unread ?? 0
|
||||
const installedNavSlugs = new Set(installables.filter(item => item.installed).map(item => item.slug))
|
||||
const terminalInstalled = installedNavSlugs.has('terminal')
|
||||
const desktopNav = PRIMARY_NAV.filter(item => installedNavSlugs.has(item.path.slice(1)))
|
||||
const overflowInstallables = installables.filter(item => item.installed && !!item.route && !['agents', 'tasks', 'workflows', 'terminal', 'constellation', 'terms'].includes(item.slug))
|
||||
const mobileNav = desktopNav
|
||||
|
||||
const togglePopover = (p: Popover) => setActivePopover(prev => prev === p ? null : p)
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePopover) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
const target = e.target as Node
|
||||
const refs: Record<string, React.RefObject<HTMLDivElement | null>> = {
|
||||
notif: notifRef,
|
||||
profile: profileRef,
|
||||
more: moreRef,
|
||||
}
|
||||
const ref = refs[activePopover!]
|
||||
if (ref?.current && !ref.current.contains(target)) {
|
||||
setActivePopover(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [activePopover])
|
||||
|
||||
useEffect(() => {
|
||||
setMobileOpen(false)
|
||||
setActivePopover(null)
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (mobileOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [mobileOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 640px)')
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="nbl-navbar ne-Navbar__root">
|
||||
<div className="nbl-navbar-inner">
|
||||
{/* Logo + CP group */}
|
||||
<div className="ne-Navbar__logo-group">
|
||||
<Link to={isAuthenticated ? '/chat' : '/'} className="ne-Navbar__logo">
|
||||
<img src="/icon48.png" alt="" className="ne-Navbar__logo-img" />
|
||||
<span className="ne-Navbar__logo-name ne-Navbar__logo-name--hide-mobile">NebulaOS</span>
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<Link
|
||||
to="/apps"
|
||||
className={cn('nbl-icon-btn', pathname === '/apps' && 'active')}
|
||||
aria-label="Apps"
|
||||
title="Apps"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<LayoutGrid size={13} />
|
||||
</Link>
|
||||
)}
|
||||
{/* CP button removed — accessible via /control or hamburger menu */}
|
||||
</div>
|
||||
|
||||
{/* Nav zone: links + mega only — hover here triggers mega */}
|
||||
<div className="ne-Navbar__nav-zone ne-Navbar__nav-group--desktop">
|
||||
<div className="ne-Navbar__nav-group">
|
||||
{isAuthenticated && desktopNav.map(({ path, label, icon: Icon, countKey }) => {
|
||||
const active = pathname === path || (path !== '/' && pathname.startsWith(path))
|
||||
const count = countKey ? counts[countKey] : 0
|
||||
return (
|
||||
<Link
|
||||
key={path}
|
||||
to={path}
|
||||
className={cn('nbl-nav-link', active && 'active')}
|
||||
>
|
||||
<Icon size={13} />
|
||||
{label}
|
||||
{countKey && count > 0 && <span className="ne-Navbar__count">{count}</span>}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{isAuthenticated && overflowInstallables.length > 0 && (
|
||||
<div ref={moreRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePopover('more')}
|
||||
className={cn('nbl-icon-btn', 'ne-Navbar__overflow-hint', activePopover === 'more' && 'ne-Navbar__overflow-hint--active')}
|
||||
aria-label="More apps"
|
||||
title="More apps"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{activePopover === 'more' && (
|
||||
<div className="ne-Navbar__profile-dropdown" style={{ right: 0, left: 'auto', minWidth: 220 }}>
|
||||
<div className="ne-Navbar__profile-dropdown-name">More apps</div>
|
||||
{overflowInstallables.map(item => (
|
||||
<Link
|
||||
key={item.slug}
|
||||
to={item.route!}
|
||||
onClick={() => setActivePopover(null)}
|
||||
className={cn('ne-Navbar__profile-dropdown-item', pathname === item.route && 'active')}
|
||||
>
|
||||
<LayoutGrid size={12} />{item.name}
|
||||
</Link>
|
||||
))}
|
||||
<div className="ne-Navbar__profile-dropdown-divider" />
|
||||
<Link
|
||||
to="/apps"
|
||||
onClick={() => setActivePopover(null)}
|
||||
className="ne-Navbar__profile-dropdown-item"
|
||||
>
|
||||
<LayoutGrid size={12} />All apps
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side — tools, notifications, profile */}
|
||||
<div className="ne-Navbar__right">
|
||||
{isAuthenticated && (
|
||||
<div className="ne-Navbar__interaction-cluster ne-Navbar__nav-group--desktop">
|
||||
<Link
|
||||
to="/chat"
|
||||
className={cn('ne-Navbar__chat-cta', pathname === '/chat' && 'ne-Navbar__chat-cta--active')}
|
||||
title="Chat"
|
||||
>
|
||||
<ChatAiIcon size={15} />
|
||||
</Link>
|
||||
{terminalInstalled && (
|
||||
<>
|
||||
<button
|
||||
className={cn('nbl-icon-btn', 'ne-Navbar__terminal-cta', terminalVisible && !terminalCollapsed && 'ne-Navbar__terminal-cta--active')}
|
||||
onClick={() => {
|
||||
if (terminalVisible && !terminalCollapsed) {
|
||||
setTerminalCollapsed(true)
|
||||
setTerminalVisible(false)
|
||||
return
|
||||
}
|
||||
setTerminalVisible(true)
|
||||
setTerminalCollapsed(false)
|
||||
}}
|
||||
aria-label="Toggle terminal"
|
||||
title="Terminal"
|
||||
>
|
||||
<TerminalSquare size={15} />
|
||||
</button>
|
||||
<div className="ne-Navbar__partition-bar" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Search trigger — all authenticated users */}
|
||||
{isAuthenticated && (
|
||||
<button
|
||||
className="ne-Navbar__search-btn"
|
||||
onClick={() => (window as any).__NEBULA_SEARCH_OPEN__?.()}
|
||||
aria-label="Search (⌘K)"
|
||||
title="Search ⌘K"
|
||||
>
|
||||
<Search size={13} />
|
||||
<span className="ne-Navbar__search-btn-label">Search</span>
|
||||
<span className="ne-Navbar__search-kbd">⌘K</span>
|
||||
</button>
|
||||
)}
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
{/* Single system status dot — network + bridge + runtime */}
|
||||
<SystemStatusDot
|
||||
network={networkStatus}
|
||||
liveStats={liveStats}
|
||||
onClick={() => setStatusModalOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Notifications bell */}
|
||||
<div ref={notifRef} className="ne-Navbar__notif-wrap">
|
||||
<button
|
||||
onClick={() => togglePopover('notif')}
|
||||
className={cn('nbl-icon-btn ne-Navbar__notif-btn', activePopover === 'notif' && 'ne-Navbar__notif-btn--active')}
|
||||
aria-label="Notifications"
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell size={14} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="ne-Navbar__notif-badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
{activePopover === 'notif' && <NotificationsPanel onClose={() => setActivePopover(null)} />}
|
||||
</div>
|
||||
|
||||
<div ref={profileRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => togglePopover('profile')}
|
||||
className="ne-Navbar__profile-btn"
|
||||
aria-label="Your profile"
|
||||
title={profile.name}
|
||||
>
|
||||
{profile.avatar
|
||||
? <img src={profile.avatar} alt={profile.name} className="ne-Navbar__profile-img" />
|
||||
: <span className="ne-Navbar__profile-initials">{profile.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}</span>
|
||||
}
|
||||
</button>
|
||||
{activePopover === 'profile' && (
|
||||
<div className="ne-Navbar__profile-dropdown">
|
||||
<div className="ne-Navbar__profile-dropdown-name">{profile.name}</div>
|
||||
<Link to="/docs" onClick={() => setActivePopover(null)} className="ne-Navbar__profile-dropdown-item">
|
||||
<BookOpen size={12} />Docs
|
||||
</Link>
|
||||
<Link to="/settings" onClick={() => setActivePopover(null)} className="ne-Navbar__profile-dropdown-item">
|
||||
<Settings size={12} />Settings
|
||||
</Link>
|
||||
<Link to="/profile" onClick={() => setActivePopover(null)} className="ne-Navbar__profile-dropdown-item">
|
||||
<User size={12} />Profile
|
||||
</Link>
|
||||
<div className="ne-Navbar__profile-dropdown-divider" />
|
||||
<div className="ne-Navbar__profile-dropdown-quota-row">
|
||||
<QuotaStatusWidget compact />
|
||||
</div>
|
||||
<div className="ne-Navbar__profile-dropdown-divider" />
|
||||
<button onClick={() => { setActivePopover(null); logout() }} className="ne-Navbar__profile-dropdown-item ne-Navbar__profile-dropdown-item--danger">
|
||||
<LogOut size={12} />Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => iam.login()}
|
||||
style={{
|
||||
fontSize: 13, fontWeight: 600, padding: '6px 14px',
|
||||
borderRadius: 8, background: 'rgba(100,112,241,0.12)',
|
||||
border: '1px solid rgba(100,112,241,0.3)',
|
||||
color: '#818cf8', cursor: 'pointer',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
)}
|
||||
<DevSimulator />
|
||||
<button
|
||||
onClick={e => {
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
toggleWithTransition({ x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) })
|
||||
}}
|
||||
className="nbl-icon-btn ne-Navbar__theme-btn"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'light' ? <Moon size={13} /> : <Sun size={13} />}
|
||||
</button>
|
||||
|
||||
{/* Hamburger — mobile only */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(o => !o)}
|
||||
className="nbl-icon-btn ne-Navbar__hamburger"
|
||||
aria-label={mobileOpen ? 'Close navigation' : 'Open navigation'}
|
||||
>
|
||||
{mobileOpen ? <X size={18} /> : <Menu size={18} />}
|
||||
</button>
|
||||
</div>{/* end ne-Navbar__right-area */}
|
||||
</div>{/* end nbl-navbar-inner */}
|
||||
|
||||
</nav >
|
||||
|
||||
{/* Network alert banner — visible when degraded/offline */}
|
||||
<NetworkAlertBanner network={networkStatus} onDetails={() => setStatusModalOpen(true)} />
|
||||
|
||||
{/* System status modal / bottom drawer */}
|
||||
{statusModalOpen && (
|
||||
<SystemStatusModal
|
||||
network={networkStatus}
|
||||
liveStats={liveStats}
|
||||
onClose={() => setStatusModalOpen(false)}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
mobileOpen && (
|
||||
<div className="ne-Navbar__mobile-backdrop" onClick={() => setMobileOpen(false)} />
|
||||
)
|
||||
}
|
||||
<div className={cn('ne-Navbar__mobile-drawer', mobileOpen && 'ne-Navbar__mobile-drawer--open')}>
|
||||
<div className="ne-Navbar__mobile-drawer-inner">
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<div className="ne-Navbar__mobile-section-label">Navigation</div>
|
||||
{mobileNav.map(({ path, label, icon: Icon }) => {
|
||||
const active = pathname === path || (path !== '/' && path !== '/blog' && pathname.startsWith(path))
|
||||
return (
|
||||
<Link
|
||||
key={path}
|
||||
to={path}
|
||||
className={cn('ne-Navbar__mobile-link', active && 'ne-Navbar__mobile-link--active')}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{label}
|
||||
{active && <span className="ne-Navbar__mobile-active-dot" />}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
<div className="ne-Navbar__mobile-divider" />
|
||||
</>
|
||||
)}
|
||||
<div className="ne-Navbar__mobile-section-label">More</div>
|
||||
{isAuthenticated && (
|
||||
<Link
|
||||
to="/settings"
|
||||
className={cn('ne-Navbar__mobile-link', pathname.startsWith('/settings') && 'ne-Navbar__mobile-link--active')}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<Settings size={16} />
|
||||
Settings
|
||||
{pathname.startsWith('/settings') && <span className="ne-Navbar__mobile-active-dot" />}
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to="/blog"
|
||||
className={cn('ne-Navbar__mobile-link', pathname.startsWith('/blog') && 'ne-Navbar__mobile-link--active')}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<Library size={16} />
|
||||
Blog
|
||||
{pathname.startsWith('/blog') && <span className="ne-Navbar__mobile-active-dot" />}
|
||||
</Link>
|
||||
<Link
|
||||
to="/qna"
|
||||
className={cn('ne-Navbar__mobile-link', pathname.startsWith('/qna') && 'ne-Navbar__mobile-link--active')}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<MessagesSquare size={16} />
|
||||
Q&A
|
||||
{pathname.startsWith('/qna') && <span className="ne-Navbar__mobile-active-dot" />}
|
||||
</Link>
|
||||
<a href="/docs" className="ne-Navbar__mobile-link" onClick={() => setMobileOpen(false)}>
|
||||
<BookOpen size={16} />
|
||||
Docs
|
||||
</a>
|
||||
{isAuthenticated && terminalInstalled && (
|
||||
<button
|
||||
className="ne-Navbar__mobile-link"
|
||||
onClick={() => {
|
||||
if (terminalVisible && !terminalCollapsed) {
|
||||
setTerminalCollapsed(true)
|
||||
setTerminalVisible(false)
|
||||
} else {
|
||||
setTerminalVisible(true)
|
||||
setTerminalCollapsed(false)
|
||||
}
|
||||
setMobileOpen(false)
|
||||
}}
|
||||
>
|
||||
<TerminalSquare size={16} />
|
||||
Terminal
|
||||
</button>
|
||||
)}
|
||||
<div className="ne-Navbar__mobile-divider" />
|
||||
<button className="ne-Navbar__mobile-status" onClick={() => setStatusModalOpen(true)}>
|
||||
<SystemStatusDot
|
||||
network={networkStatus}
|
||||
liveStats={liveStats}
|
||||
interactive={false}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--nbl-text-secondary)' }}>System status</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
import { GlobalContextPanel } from './SessionsPanel'
|
||||
import { ChatWorkspace } from './ChatWorkspace'
|
||||
import { RightPanel } from './RightPanel'
|
||||
import { useProMode } from '@/hooks/useProMode'
|
||||
import { useViewMode } from '@/context/ViewModeContext'
|
||||
import { useShell } from '@/context/ShellContext'
|
||||
import { AssistantDashboard } from '@/pages/AssistantDashboard'
|
||||
import { LayoutDashboard } from 'lucide-react'
|
||||
import { approvalsApi, chatSessionsApi, tasksApi } from '@/api/client'
|
||||
|
||||
// ── Mobile detection hook ─────────────────────────────────────────────────────
|
||||
function useIsMobile() {
|
||||
const [mobile, setMobile] = useState(() => window.innerWidth <= 640)
|
||||
const [tablet, setTablet] = useState(() => window.innerWidth > 640 && window.innerWidth <= 768)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 40rem)')
|
||||
const mqTablet = window.matchMedia('(min-width: 40.0625rem) and (max-width: 48rem)')
|
||||
const onMQ = (e: MediaQueryListEvent) => setMobile(e.matches)
|
||||
const onMQTablet = (e: MediaQueryListEvent) => setTablet(e.matches)
|
||||
mq.addEventListener('change', onMQ)
|
||||
mqTablet.addEventListener('change', onMQTablet)
|
||||
return () => { mq.removeEventListener('change', onMQ); mqTablet.removeEventListener('change', onMQTablet) }
|
||||
}, [])
|
||||
return { isMobile: mobile, isTablet: tablet }
|
||||
}
|
||||
|
||||
type MobilePane = 'global' | 'chat' | 'inspector'
|
||||
|
||||
// Routes always rendered full-page (no 3-pane, no mode guard)
|
||||
const FULLPAGE_ROUTES = [
|
||||
'/settings', '/profile', '/docs', '/blog', '/blogs', '/qna',
|
||||
]
|
||||
|
||||
// Routes shown in 3-pane center when pro mode is on
|
||||
const PRO_PAGE_ROUTES = [
|
||||
'/control', '/apps', '/surfaces', '/agents', '/tasks', '/plugins', '/policies', '/security',
|
||||
'/security-center', '/logs', '/integrations', '/models', '/context-bank',
|
||||
'/marketplace', '/approvals', '/knowledge', '/workflows',
|
||||
'/build', '/analytics', '/admin', '/quota', '/memory',
|
||||
]
|
||||
|
||||
function isFullPageRoute(pathname: string): boolean {
|
||||
return FULLPAGE_ROUTES.some(r => pathname === r || pathname.startsWith(r + '/'))
|
||||
}
|
||||
|
||||
function isProPageRoute(pathname: string): boolean {
|
||||
return PRO_PAGE_ROUTES.some(r => pathname === r || pathname.startsWith(r + '/'))
|
||||
}
|
||||
|
||||
// ── Storage keys ──────────────────────────────────────────────────────────────
|
||||
const LEFT_KEY = 'nebula_shell_left_ratio'
|
||||
const RIGHT_KEY = 'nebula_shell_right_ratio'
|
||||
|
||||
// ── Defaults and constraints ──────────────────────────────────────────────────
|
||||
const DEFAULT_LEFT = 0.20 // 20% sessions panel
|
||||
const DEFAULT_RIGHT = 0.27 // 27% right panel
|
||||
const MIN_LEFT = 0.12
|
||||
const MAX_LEFT = 0.35
|
||||
const MIN_RIGHT = 0.18
|
||||
const MAX_RIGHT = 0.45
|
||||
|
||||
function clamp(v: number, lo: number, hi: number) {
|
||||
return Math.max(lo, Math.min(hi, v))
|
||||
}
|
||||
|
||||
function readRatio(key: string, fallback: number): number {
|
||||
try {
|
||||
const v = localStorage.getItem(key)
|
||||
if (v === null) return fallback
|
||||
const n = parseFloat(v)
|
||||
return isNaN(n) ? fallback : n
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resize handle ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface HandleProps {
|
||||
onMouseDown: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
function ResizeHandle({ onMouseDown }: HandleProps) {
|
||||
return (
|
||||
<div
|
||||
onMouseDown={onMouseDown}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
style={{
|
||||
width: 4,
|
||||
flexShrink: 0,
|
||||
cursor: 'col-resize',
|
||||
background: 'var(--nbl-border)',
|
||||
transition: 'background 0.15s',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.background = 'var(--nbl-cyan)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.background = 'var(--nbl-border)'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shell layout ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function ShellLayout() {
|
||||
const { pathname } = useLocation()
|
||||
const { proMode } = useProMode()
|
||||
const { isAssist } = useViewMode()
|
||||
const { chatExpanded, setChatExpanded, selectedItem, focusedChatMessage, activeSessionId, allChatMessages } = useShell()
|
||||
const { isMobile, isTablet } = useIsMobile()
|
||||
const [mobilePane, setMobilePane] = useState<MobilePane>('chat')
|
||||
|
||||
// Full-page routes (settings/profile/blog/docs): render as Outlet without 3-pane shell
|
||||
const fullPage = isFullPageRoute(pathname)
|
||||
// Pro-mode page routes: render Outlet in center panel when pro mode is on
|
||||
const showPageInCenter = proMode && isProPageRoute(pathname)
|
||||
// Assistant mode: show dashboard on home route unless chat is expanded
|
||||
const showDashboard = isAssist && !chatExpanded && pathname === '/chat'
|
||||
|
||||
const [leftRatio, setLeftRatio] = useState(() => clamp(readRatio(LEFT_KEY, DEFAULT_LEFT), MIN_LEFT, MAX_LEFT))
|
||||
const [rightRatio, setRightRatio] = useState(() => clamp(readRatio(RIGHT_KEY, DEFAULT_RIGHT), MIN_RIGHT, MAX_RIGHT))
|
||||
const touchStartX = useRef(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const draggingRef = useRef<'left' | 'right' | null>(null)
|
||||
const rafRef = useRef<number>(0)
|
||||
|
||||
// Persist to localStorage on change
|
||||
useEffect(() => { localStorage.setItem(LEFT_KEY, String(leftRatio)) }, [leftRatio])
|
||||
useEffect(() => { localStorage.setItem(RIGHT_KEY, String(rightRatio)) }, [rightRatio])
|
||||
|
||||
const startDrag = useCallback((which: 'left' | 'right') => (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
draggingRef.current = which
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!draggingRef.current || !containerRef.current) return
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
const rect = containerRef.current!.getBoundingClientRect()
|
||||
const frac = (ev.clientX - rect.left) / rect.width
|
||||
|
||||
if (draggingRef.current === 'left') {
|
||||
setLeftRatio(clamp(frac, MIN_LEFT, MAX_LEFT))
|
||||
} else {
|
||||
// right handle: frac is distance from left edge, so right ratio = 1 - frac
|
||||
const rr = clamp(1 - frac, MIN_RIGHT, MAX_RIGHT)
|
||||
setRightRatio(rr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
draggingRef.current = null
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
document.removeEventListener('mousemove', onMove)
|
||||
document.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMove)
|
||||
document.addEventListener('mouseup', onUp)
|
||||
}, [])
|
||||
|
||||
// Full-page routes bypass 3-pane shell entirely
|
||||
if (fullPage) {
|
||||
return (
|
||||
<main id="ne-main-scroll" style={{ flex: 1, overflowY: 'auto', width: '100%' }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
// Left panel only makes sense on the chat route — sessions are chat-specific
|
||||
const isChatRoute = pathname === '/chat'
|
||||
|
||||
const { data: sessionDetail } = useQuery({
|
||||
queryKey: ['chat-session-detail', activeSessionId],
|
||||
queryFn: () => chatSessionsApi.get(activeSessionId!),
|
||||
enabled: isChatRoute && !!activeSessionId,
|
||||
staleTime: 15_000,
|
||||
refetchInterval: 20_000,
|
||||
})
|
||||
|
||||
const sessionTaskIds = Array.from(new Set((sessionDetail?.entities ?? [])
|
||||
.filter(item => item.entity_type === 'task')
|
||||
.map(item => item.entity_id)))
|
||||
|
||||
const sessionApprovalIds = Array.from(new Set(allChatMessages
|
||||
.map(msg => msg.approvalId)
|
||||
.filter((id): id is string => !!id)))
|
||||
|
||||
const { data: sessionTasks = [] } = useQuery({
|
||||
queryKey: ['chat-session-tasks', activeSessionId, sessionTaskIds],
|
||||
queryFn: () => Promise.all(sessionTaskIds.map(id => tasksApi.get(id))),
|
||||
enabled: isChatRoute && !!activeSessionId && sessionTaskIds.length > 0,
|
||||
staleTime: 5_000,
|
||||
refetchInterval: 8_000,
|
||||
})
|
||||
|
||||
const { data: sessionApprovals = [] } = useQuery({
|
||||
queryKey: ['chat-session-approvals', activeSessionId, sessionApprovalIds],
|
||||
queryFn: () => Promise.all(sessionApprovalIds.map(id => approvalsApi.get(id))),
|
||||
enabled: isChatRoute && !!activeSessionId && sessionApprovalIds.length > 0,
|
||||
staleTime: 5_000,
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
|
||||
const runtimeCount = (sessionApprovals.filter(approval => approval.status === 'pending').length)
|
||||
+ (allChatMessages.filter(message => message.role === 'agent').length)
|
||||
+ (sessionTasks.filter(task => task.status === 'running').length)
|
||||
+ ([...sessionTasks].filter(task => task.status !== 'running').slice(0, 8).length)
|
||||
+ ((sessionDetail?.entities ?? []).length)
|
||||
|
||||
// ── Mobile: swipe-based single-pane with top-corner indicators ────────────
|
||||
if (isMobile && isChatRoute) {
|
||||
const PANES: MobilePane[] = ['global', 'chat', 'inspector']
|
||||
const paneIndex = PANES.indexOf(mobilePane)
|
||||
|
||||
const centerContent = showDashboard ? (
|
||||
<AssistantDashboard />
|
||||
) : showPageInCenter ? (
|
||||
<main id="ne-main-scroll" style={{ flex: 1, overflowY: 'auto' }}><Outlet /></main>
|
||||
) : (
|
||||
<ChatWorkspace />
|
||||
)
|
||||
|
||||
// Touch swipe handler
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX
|
||||
}
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current
|
||||
if (Math.abs(dx) < 50) return
|
||||
if (dx < 0 && paneIndex < PANES.length - 1) setMobilePane(PANES[paneIndex + 1])
|
||||
if (dx > 0 && paneIndex > 0) setMobilePane(PANES[paneIndex - 1])
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%', overflow: 'hidden', position: 'relative' }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* ── Active pane ──────────────────────────────────────────────── */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{mobilePane === 'global' && <GlobalContextPanel />}
|
||||
{mobilePane === 'chat' && centerContent}
|
||||
{mobilePane === 'inspector' && <RightPanel />}
|
||||
</div>
|
||||
|
||||
{/* ── Left edge strip: tap or swipe right to go to previous pane ── */}
|
||||
{paneIndex > 0 && (
|
||||
<button
|
||||
className="ne-Shell__edge-strip ne-Shell__edge-strip--left"
|
||||
onClick={() => setMobilePane(PANES[paneIndex - 1])}
|
||||
aria-label="Previous pane"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Right edge strip: tap or swipe left to go to next pane ──── */}
|
||||
{paneIndex < PANES.length - 1 && (
|
||||
<button
|
||||
className="ne-Shell__edge-strip ne-Shell__edge-strip--right"
|
||||
onClick={() => setMobilePane(PANES[paneIndex + 1])}
|
||||
aria-label="Next pane"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tablet: hide left panel, center + right only ────────────────────────
|
||||
const hideLeft = isTablet || !isChatRoute
|
||||
const inspectorOpen = isChatRoute && (!!selectedItem || !!focusedChatMessage)
|
||||
const rightPanelOpen = isChatRoute && (inspectorOpen || runtimeCount > 0)
|
||||
|
||||
const leftPct = `${(leftRatio * 100).toFixed(2)}%`
|
||||
const rightPct = rightPanelOpen
|
||||
? isTablet ? '40%' : `${(rightRatio * 100).toFixed(2)}%`
|
||||
: '0%'
|
||||
const centerPct = rightPanelOpen
|
||||
? hideLeft
|
||||
? isTablet ? '60%' : `${((1 - rightRatio) * 100).toFixed(2)}%`
|
||||
: `${((1 - leftRatio - rightRatio) * 100).toFixed(2)}%`
|
||||
: hideLeft
|
||||
? '100%'
|
||||
: `${((1 - leftRatio) * 100).toFixed(2)}%`
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* ── Left: Global Context (chat route only, desktop) ──────────── */}
|
||||
{isChatRoute && !isTablet && (
|
||||
<>
|
||||
<div style={{
|
||||
width: leftPct,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRight: '1px solid var(--nbl-border)',
|
||||
background: 'var(--nbl-bg-base)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<GlobalContextPanel />
|
||||
</div>
|
||||
<ResizeHandle onMouseDown={startDrag('left')} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Center: Dashboard / Chat / Page ─────────────────────────── */}
|
||||
<div style={{
|
||||
width: centerPct,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--nbl-bg-base)',
|
||||
}}>
|
||||
{showDashboard ? (
|
||||
<AssistantDashboard />
|
||||
) : showPageInCenter ? (
|
||||
<main id="ne-main-scroll" style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
) : (
|
||||
<>
|
||||
{isAssist && chatExpanded && pathname === '/chat' && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 10px',
|
||||
borderBottom: '1px solid var(--nbl-border)',
|
||||
background: 'var(--nbl-bg-base)',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setChatExpanded(false)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '3px 8px', borderRadius: 6,
|
||||
border: '1px solid var(--nbl-border)',
|
||||
background: 'transparent',
|
||||
color: 'var(--nbl-text-ghost)',
|
||||
fontSize: 11, cursor: 'pointer',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--nbl-text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--nbl-text-ghost)')}
|
||||
>
|
||||
<LayoutDashboard size={11} />
|
||||
Dashboard
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ChatWorkspace />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Right: Chat-scoped runtime / inspector ────────────────────── */}
|
||||
{rightPanelOpen && !isTablet && <ResizeHandle onMouseDown={startDrag('right')} />}
|
||||
{rightPanelOpen && (
|
||||
<div style={{
|
||||
width: rightPct,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderLeft: '1px solid var(--nbl-border)',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--nbl-bg-base)',
|
||||
}}>
|
||||
<RightPanel />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
import { useMemo, useState, type ElementType } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Bot,
|
||||
GitBranch,
|
||||
LayoutGrid,
|
||||
ListTodo,
|
||||
Package,
|
||||
Plug,
|
||||
Shield,
|
||||
BookOpen,
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { PageShell } from '@/components/nebula-ux'
|
||||
import { agentsApi, corporaApi, installablesApi, integrationsApi, pluginsApi, policiesApi, workflowsApi, type ApiInstallable } from '@/api/client'
|
||||
|
||||
const INSTALLABLE_ICONS: Record<string, ElementType> = {
|
||||
bot: Bot,
|
||||
'git-branch': GitBranch,
|
||||
'list-todo': ListTodo,
|
||||
package: Package,
|
||||
plug: Plug,
|
||||
shield: Shield,
|
||||
'shield-check': Shield,
|
||||
'book-open': BookOpen,
|
||||
book: BookOpen,
|
||||
sparkles: LayoutGrid,
|
||||
'layout-grid': LayoutGrid,
|
||||
}
|
||||
|
||||
type InstalledApp = {
|
||||
id: string
|
||||
name: string
|
||||
route: string
|
||||
icon: ElementType
|
||||
accent: string
|
||||
description: string
|
||||
countLabel: string
|
||||
countValue?: number
|
||||
installable?: ApiInstallable
|
||||
}
|
||||
|
||||
export function AppsPage() {
|
||||
const qc = useQueryClient()
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: ['apps-agents'],
|
||||
queryFn: () => agentsApi.list({ page_size: 50 }),
|
||||
})
|
||||
const { data: workflows } = useQuery({
|
||||
queryKey: ['apps-workflows'],
|
||||
queryFn: () => workflowsApi.list({ page_size: 50 }),
|
||||
})
|
||||
const { data: plugins } = useQuery({
|
||||
queryKey: ['apps-plugins'],
|
||||
queryFn: () => pluginsApi.list({ page_size: 100 }),
|
||||
})
|
||||
const { data: policies } = useQuery({
|
||||
queryKey: ['apps-policies'],
|
||||
queryFn: () => policiesApi.list({ page_size: 50 }),
|
||||
})
|
||||
const { data: knowledge } = useQuery({
|
||||
queryKey: ['apps-knowledge'],
|
||||
queryFn: () => corporaApi.list({ page_size: 50 }),
|
||||
})
|
||||
const { data: integrations } = useQuery({
|
||||
queryKey: ['apps-integrations'],
|
||||
queryFn: () => integrationsApi.instances(),
|
||||
})
|
||||
const { data: installables = [] } = useQuery({
|
||||
queryKey: ['installables'],
|
||||
queryFn: () => installablesApi.list(),
|
||||
})
|
||||
const [busyInstallable, setBusyInstallable] = useState<string | null>(null)
|
||||
|
||||
const installableMut = useMutation({
|
||||
mutationFn: (item: ApiInstallable) => item.installed ? installablesApi.uninstall(item.slug) : installablesApi.install(item.slug),
|
||||
onMutate: (item) => setBusyInstallable(item.slug),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['installables'] }),
|
||||
onSettled: () => setBusyInstallable(null),
|
||||
})
|
||||
|
||||
const enabledPlugins = (plugins?.items ?? []).filter(plugin => plugin.enabled)
|
||||
const installableMap = new Map(installables.map(item => [item.slug, item]))
|
||||
|
||||
const installedApps = useMemo<InstalledApp[]>(() => {
|
||||
const coreApps: InstalledApp[] = [
|
||||
{
|
||||
id: 'agents',
|
||||
name: 'Agents',
|
||||
route: '/agents',
|
||||
icon: Bot,
|
||||
accent: 'var(--nbl-cyan)',
|
||||
description: 'Create, inspect, and operate the agent workforce behind your runtime.',
|
||||
countLabel: 'Installed',
|
||||
countValue: agents?.total ?? agents?.items?.length ?? 0,
|
||||
installable: installableMap.get('agents'),
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
name: 'Tasks',
|
||||
route: '/tasks',
|
||||
icon: ListTodo,
|
||||
accent: 'var(--nbl-green)',
|
||||
description: 'Track live executions, retries, failures, and final outcomes.',
|
||||
countLabel: 'Installed',
|
||||
installable: installableMap.get('tasks'),
|
||||
},
|
||||
{
|
||||
id: 'workflows',
|
||||
name: 'Workflows',
|
||||
route: '/workflows',
|
||||
icon: GitBranch,
|
||||
accent: 'var(--nbl-amber)',
|
||||
description: 'Design and run repeatable automations with observable execution paths.',
|
||||
countLabel: 'Installed',
|
||||
countValue: workflows?.total ?? workflows?.items?.length ?? 0,
|
||||
installable: installableMap.get('workflows'),
|
||||
},
|
||||
].filter(app => app.installable?.installed)
|
||||
|
||||
if (installableMap.get('plugins')?.installed) {
|
||||
coreApps.push({
|
||||
id: 'plugins',
|
||||
name: 'Plugins',
|
||||
route: '/plugins',
|
||||
icon: Package,
|
||||
accent: 'var(--nbl-purple)',
|
||||
description: 'Installed tools and extensions that expand what your runtime can do.',
|
||||
countLabel: 'Enabled',
|
||||
countValue: enabledPlugins.length,
|
||||
installable: installableMap.get('plugins'),
|
||||
})
|
||||
}
|
||||
|
||||
if (installableMap.get('policies')?.installed) {
|
||||
coreApps.push({
|
||||
id: 'policies',
|
||||
name: 'Policies',
|
||||
route: '/policies',
|
||||
icon: Shield,
|
||||
accent: 'var(--nbl-red)',
|
||||
description: 'Guardrails, approvals, and enforcement logic active in your system.',
|
||||
countLabel: 'Active',
|
||||
countValue: policies?.items?.length ?? 0,
|
||||
installable: installableMap.get('policies'),
|
||||
})
|
||||
}
|
||||
|
||||
if (installableMap.get('knowledge')?.installed) {
|
||||
coreApps.push({
|
||||
id: 'knowledge',
|
||||
name: 'Knowledge',
|
||||
route: '/knowledge',
|
||||
icon: BookOpen,
|
||||
accent: 'var(--nbl-green)',
|
||||
description: 'Corpora and sources currently indexed for retrieval and memory augmentation.',
|
||||
countLabel: 'Corpora',
|
||||
countValue: knowledge?.corpora?.length ?? 0,
|
||||
installable: installableMap.get('knowledge'),
|
||||
})
|
||||
}
|
||||
|
||||
if (installableMap.get('integrations')?.installed) {
|
||||
coreApps.push({
|
||||
id: 'integrations',
|
||||
name: 'Integrations',
|
||||
route: '/integrations',
|
||||
icon: Plug,
|
||||
accent: 'var(--nbl-cyan)',
|
||||
description: 'Connected services and external systems available to your runtime.',
|
||||
countLabel: 'Connected',
|
||||
countValue: integrations?.length ?? 0,
|
||||
installable: installableMap.get('integrations'),
|
||||
})
|
||||
}
|
||||
|
||||
const knownIds = new Set(coreApps.map(app => app.id))
|
||||
const extraApps = installables
|
||||
.filter(item => item.installed && !!item.route)
|
||||
.filter(item => !['constellation', 'terms', 'terminal'].includes(item.slug))
|
||||
.filter(item => !knownIds.has(item.slug))
|
||||
.map<InstalledApp>(item => ({
|
||||
id: item.slug,
|
||||
name: item.name,
|
||||
route: item.route!,
|
||||
icon: INSTALLABLE_ICONS[item.icon ?? 'layout-grid'] ?? LayoutGrid,
|
||||
accent: 'var(--nbl-nebula)',
|
||||
description: item.description,
|
||||
countLabel: item.kind === 'workspace' ? 'Installed' : item.kind === 'runtime' ? 'Enabled' : 'Available',
|
||||
installable: item,
|
||||
}))
|
||||
|
||||
return [...coreApps, ...extraApps]
|
||||
}, [agents?.items?.length, agents?.total, enabledPlugins.length, installableMap, installables, integrations?.length, knowledge?.corpora?.length, policies?.items?.length, workflows?.items?.length, workflows?.total])
|
||||
|
||||
return (
|
||||
<PageShell page="ne-AppsPage">
|
||||
<div className="ne-AppsPage__header">
|
||||
<div>
|
||||
<div className="ne-AppsPage__title-row">
|
||||
<LayoutGrid size={18} style={{ color: 'var(--nbl-nebula)' }} />
|
||||
<h1 className="ne-AppsPage__title">Apps</h1>
|
||||
</div>
|
||||
<p className="ne-AppsPage__subtitle">Open installed NebulaOS apps and workspace surfaces from one place. Chat remains native and is always available from the dedicated chat button.</p>
|
||||
</div>
|
||||
<Link to="/marketplace" className="ne-AppsPage__market-link">
|
||||
Open Marketplace
|
||||
<ChevronRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{installedApps.length === 0 && (
|
||||
<div className="ne-AppsPage__empty">
|
||||
<div className="ne-AppsPage__empty-icon">
|
||||
<LayoutGrid size={18} />
|
||||
</div>
|
||||
<div className="ne-AppsPage__empty-title">No installed apps yet</div>
|
||||
<div className="ne-AppsPage__empty-copy">
|
||||
Install agents, tasks, workflows, or other workspace apps from the marketplace and they will appear here automatically. Chat remains native and is always available from the header.
|
||||
</div>
|
||||
<div className="ne-AppsPage__empty-actions">
|
||||
<Link to="/marketplace" className="ne-AppsPage__market-link">
|
||||
Open Marketplace
|
||||
<ChevronRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ne-AppsPage__grid">
|
||||
{installedApps.map((app, index) => {
|
||||
const Icon = app.icon
|
||||
const installable = app.installable
|
||||
const removable = !!app.installable && !app.installable.default_installed
|
||||
const busy = busyInstallable === installable?.slug
|
||||
return (
|
||||
<Link
|
||||
key={app.id}
|
||||
to={app.route}
|
||||
className="ne-AppsPage__card"
|
||||
data-installed="true"
|
||||
style={{ ['--app-accent' as string]: app.accent, ['--app-index' as string]: String(index) }}
|
||||
>
|
||||
<div className="ne-AppsPage__card-top">
|
||||
<div className="ne-AppsPage__icon-wrap">
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<div className="ne-AppsPage__badge">
|
||||
<CheckCircle2 size={11} />
|
||||
{app.countValue != null ? `${app.countLabel} · ${app.countValue}` : app.countLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ne-AppsPage__card-body">
|
||||
<div className="ne-AppsPage__card-title">{app.name}</div>
|
||||
<div className="ne-AppsPage__card-desc">{app.description}</div>
|
||||
</div>
|
||||
<div className="ne-AppsPage__card-footer">
|
||||
<span>Open</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{removable && installable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
installableMut.mutate(installable)
|
||||
}}
|
||||
disabled={busy}
|
||||
className="ne-AppsPage__remove-btn"
|
||||
title="Remove installed app"
|
||||
>
|
||||
{busy ? <Loader2 size={12} className="ne-spin" /> : <Trash2 size={12} />}
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
<ChevronRight size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { AppsPage } from './AppsPage'
|
||||
Reference in New Issue
Block a user