chore: remove pre-branch-compare backup files
All checks were successful
Stuffle/nebula-os/pipeline/head This commit was not built

This commit is contained in:
2026-05-03 18:10:34 +05:30
parent 0d1268a66b
commit beb70cff79
5 changed files with 0 additions and 1345 deletions

View File

@@ -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

View File

@@ -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&amp;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>
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -1 +0,0 @@
export { AppsPage } from './AppsPage'