Replaces the separate Entities/Graph nav items with one Knowledge Base page that browses all entities as either a table or a force-graph, scoped by ontology layer (Infrastructure/Governance/Cognition), with a resizable browse/detail split instead of a slide-over sheet. - New KnowledgeBase.svelte: layer tabs, view toggle, resizable browse/detail split (pattern from Chat.svelte's rail). - EntityTable/EntityGraph extracted as presentational sub-components; their search/filter/root/depth toolbars live in the shared page toolbar (not the resizable pane) so they don't truncate when the divider is dragged narrow, and both views start flush with the detail pane for consistent height. - EntityTable columns are sortable (slug/type/name/state/health). - EntityDetailContent redesigned as a single-column list of collapsible sections (DetailSection.svelte), collapsed by default when empty; relation entries are clickable and select the entity in the browse pane + detail pane (and drill in-place in EntitySheet wherever it's used elsewhere in the app). - api.ts: add layer filter to fetchEntities, add fetchEntityTypes for client-side graph layer scoping (the graph endpoint has no layer param). Old hash routes (#/entities, #/graph) redirect to #/kb. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
246 lines
9.7 KiB
Svelte
246 lines
9.7 KiB
Svelte
<script lang="ts">
|
|
import Chat from './pages/Chat.svelte'
|
|
import Overview from './pages/Overview.svelte'
|
|
import KnowledgeBase from './pages/KnowledgeBase.svelte'
|
|
import Ops from './pages/Ops.svelte'
|
|
import Signals from './pages/Signals.svelte'
|
|
import EntityDetail from './pages/EntityDetail.svelte'
|
|
import Knowledge from './pages/Knowledge.svelte'
|
|
import Learning from './pages/Learning.svelte'
|
|
import Config from './pages/Config.svelte'
|
|
import { newChat } from '$lib/stores/chat'
|
|
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
|
|
import { connectionState } from '$lib/stores/events'
|
|
import { isConfigured } from '$lib/config'
|
|
import { onMount } from 'svelte'
|
|
import * as Sidebar from '$lib/components/ui/sidebar'
|
|
import * as Sheet from '$lib/components/ui/sheet'
|
|
import { Button } from '$lib/components/ui/button'
|
|
import { Badge } from '$lib/components/ui/badge'
|
|
import { Separator } from '$lib/components/ui/separator'
|
|
import { Toaster } from '$lib/components/ui/sonner'
|
|
import PlusIcon from '@lucide/svelte/icons/plus'
|
|
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
|
|
import DatabaseIcon from '@lucide/svelte/icons/database'
|
|
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
|
|
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
|
import SirenIcon from '@lucide/svelte/icons/siren'
|
|
import SearchIcon from '@lucide/svelte/icons/search'
|
|
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
|
import SettingsIcon from '@lucide/svelte/icons/settings'
|
|
|
|
let page = $state('overview')
|
|
let routeParam = $state('')
|
|
let drawerOpen = $state(false)
|
|
let configured = $state(isConfigured())
|
|
|
|
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
|
|
const openSignals = $derived(openSignalCount($summary))
|
|
|
|
onMount(() => {
|
|
function sync() {
|
|
const path = location.hash.slice(2) || 'overview'
|
|
const [head, ...rest] = path.split('/')
|
|
// Entities + Graph were merged into Knowledge Base — keep old links working.
|
|
if (head === 'entities' || head === 'graph') {
|
|
location.hash = '#/kb'
|
|
return
|
|
}
|
|
page = head || 'overview'
|
|
routeParam = rest.join('/')
|
|
}
|
|
sync()
|
|
window.addEventListener('hashchange', sync)
|
|
return () => window.removeEventListener('hashchange', sync)
|
|
})
|
|
|
|
// Context (dashboard summary + approvals poll) and the SSE stream both
|
|
// authenticate — don't subscribe until a token exists.
|
|
$effect(() => {
|
|
if (!configured) return
|
|
return subscribeContext()
|
|
})
|
|
|
|
function navigate(p: string) {
|
|
location.hash = '#/' + p
|
|
}
|
|
|
|
const navItems = [
|
|
{ id: 'overview', label: 'Overview', icon: LayoutDashboardIcon },
|
|
{ id: 'kb', label: 'Knowledge Base', icon: DatabaseIcon },
|
|
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
|
|
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
|
|
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
|
|
{ id: 'learning', label: 'Learning', icon: TrendingUpIcon }
|
|
]
|
|
</script>
|
|
|
|
{#if !configured}
|
|
<Config
|
|
onConnected={() => (configured = true)}
|
|
onCancel={isConfigured() ? () => (configured = true) : undefined}
|
|
/>
|
|
{:else}
|
|
|
|
<Toaster />
|
|
|
|
<Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);">
|
|
<Sidebar.Root collapsible="icon" variant="inset">
|
|
<Sidebar.Header>
|
|
<Sidebar.Menu>
|
|
<Sidebar.MenuItem>
|
|
<Sidebar.MenuButton
|
|
class="data-[slot=sidebar-menu-button]:!p-1.5"
|
|
onclick={() => navigate('overview')}
|
|
tooltipContent="Oikos"
|
|
>
|
|
{#snippet child({ props })}
|
|
<button {...props}>
|
|
<svg viewBox="0 0 91 100" class="!size-5 shrink-0 fill-white" aria-hidden="true" role="img">
|
|
<title>Oikos</title>
|
|
<path d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z" />
|
|
</svg>
|
|
</button>
|
|
{/snippet}
|
|
</Sidebar.MenuButton>
|
|
</Sidebar.MenuItem>
|
|
</Sidebar.Menu>
|
|
<Sidebar.Menu>
|
|
<Sidebar.MenuItem>
|
|
<Sidebar.MenuButton
|
|
class="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
|
|
onclick={() => { newChat(); navigate('chat') }}
|
|
tooltipContent="New task"
|
|
>
|
|
{#snippet child({ props })}
|
|
<button {...props}>
|
|
<PlusIcon />
|
|
<span>New task</span>
|
|
</button>
|
|
{/snippet}
|
|
</Sidebar.MenuButton>
|
|
</Sidebar.MenuItem>
|
|
</Sidebar.Menu>
|
|
</Sidebar.Header>
|
|
|
|
<Sidebar.Content>
|
|
<Sidebar.Group>
|
|
<Sidebar.Menu>
|
|
{#each navItems as item}
|
|
<Sidebar.MenuItem>
|
|
<Sidebar.MenuButton
|
|
isActive={page === item.id || (item.id === 'overview' && page === 'chat')}
|
|
onclick={() => navigate(item.id)}
|
|
tooltipContent={item.label}
|
|
>
|
|
{#snippet child({ props })}
|
|
<button {...props}>
|
|
<item.icon />
|
|
<span>{item.label}</span>
|
|
</button>
|
|
{/snippet}
|
|
</Sidebar.MenuButton>
|
|
{#if item.badge?.()}
|
|
<Sidebar.MenuBadge>{item.badge()}</Sidebar.MenuBadge>
|
|
{/if}
|
|
</Sidebar.MenuItem>
|
|
{/each}
|
|
</Sidebar.Menu>
|
|
</Sidebar.Group>
|
|
</Sidebar.Content>
|
|
|
|
<Sidebar.Footer>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="justify-start gap-2"
|
|
onclick={() => (drawerOpen = true)}
|
|
title="Chat over the current page without navigating away"
|
|
>
|
|
<PanelRightIcon />
|
|
<span>Chat drawer</span>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="justify-start gap-2"
|
|
onclick={() => (configured = false)}
|
|
title="Server connection settings"
|
|
>
|
|
<SettingsIcon />
|
|
<span>Connection</span>
|
|
</Button>
|
|
</Sidebar.Footer>
|
|
</Sidebar.Root>
|
|
|
|
<Sidebar.Inset class="min-h-0 overflow-hidden">
|
|
<header class="flex h-(--header-height) shrink-0 items-center gap-1 border-b px-4 lg:gap-2 lg:px-6">
|
|
<Sidebar.Trigger class="-ms-1" />
|
|
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
|
|
{#if page === 'chat'}
|
|
<button type="button" class="text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('overview')}>Overview</button>
|
|
<span class="text-muted-foreground">/</span>
|
|
<span class="text-base font-medium">Conversation</span>
|
|
{:else}
|
|
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page === 'kb' ? 'Knowledge Base' : page}</span>
|
|
{/if}
|
|
<div class="ms-auto flex items-center gap-2.5">
|
|
{#if $summary}
|
|
<div class="hidden items-center gap-2.5 text-xs text-muted-foreground sm:flex">
|
|
<span class="flex items-center gap-1" title="healthy"><span class="size-2 rounded-full bg-success"></span>{$summary.health.healthy}</span>
|
|
<span class="flex items-center gap-1" title="degraded"><span class="size-2 rounded-full bg-warning"></span>{$summary.health.degraded}</span>
|
|
<span class="flex items-center gap-1" title="down"><span class="size-2 rounded-full bg-destructive"></span>{$summary.health.down}</span>
|
|
</div>
|
|
{#if approvalsPending}
|
|
<button type="button" onclick={() => navigate('ops')}>
|
|
<Badge variant="destructive" class="cursor-pointer">{approvalsPending} approval{approvalsPending === 1 ? '' : 's'}</Badge>
|
|
</button>
|
|
{/if}
|
|
{#if openSignals}
|
|
<button type="button" onclick={() => navigate('signals')}>
|
|
<Badge variant="secondary" class="cursor-pointer">{openSignals} signal{openSignals === 1 ? '' : 's'}</Badge>
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
<span
|
|
class="size-2 rounded-full {$connectionState === 'open' ? 'bg-success' : $connectionState === 'connecting' ? 'animate-pulse bg-warning' : 'bg-destructive'}"
|
|
title="event stream: {$connectionState}"
|
|
></span>
|
|
</div>
|
|
</header>
|
|
<main class="min-h-0 flex-1 overflow-hidden">
|
|
{#if page === 'overview'}
|
|
<Overview />
|
|
{:else if page === 'kb'}
|
|
<KnowledgeBase />
|
|
{:else if page === 'entity' && routeParam}
|
|
<EntityDetail slug={routeParam} />
|
|
{:else if page === 'ops'}
|
|
<Ops />
|
|
{:else if page === 'signals'}
|
|
<Signals />
|
|
{:else if page === 'knowledge'}
|
|
<Knowledge />
|
|
{:else if page === 'learning'}
|
|
<Learning />
|
|
{:else}
|
|
<Chat />
|
|
{/if}
|
|
</main>
|
|
</Sidebar.Inset>
|
|
</Sidebar.Provider>
|
|
|
|
<Sheet.Root bind:open={drawerOpen}>
|
|
<Sheet.Content side="right" class="w-[400px] p-0 sm:max-w-[400px]">
|
|
<Sheet.Header class="sr-only">
|
|
<Sheet.Title>Nomos chat</Sheet.Title>
|
|
<Sheet.Description>Persistent chat drawer</Sheet.Description>
|
|
</Sheet.Header>
|
|
<div class="flex h-full flex-col">
|
|
<Chat showRail={false} />
|
|
</div>
|
|
</Sheet.Content>
|
|
</Sheet.Root>
|
|
|
|
{/if}
|