Fixes the reviewed gaps: keyboard-inaccessible delete controls (SessionRail, Entities row), case-sensitive entity filter, two competing entity-detail navigation patterns (standardize on EntitySheet), non-clickable Overview KPI cards, a bare button bypassing the shared Button component, inconsistent blur-only vs live filtering, and an unenforced sanitization assumption on search snippet HTML (now using the already-present dompurify dependency). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
251 lines
10 KiB
Svelte
251 lines
10 KiB
Svelte
<script lang="ts">
|
|
import Chat from './pages/Chat.svelte'
|
|
import Tasks from './pages/Tasks.svelte'
|
|
import Overview from './pages/Overview.svelte'
|
|
import Entities from './pages/Entities.svelte'
|
|
import Events from './pages/Events.svelte'
|
|
import Ops from './pages/Ops.svelte'
|
|
import Signals from './pages/Signals.svelte'
|
|
import Graph from './pages/Graph.svelte'
|
|
import EntityDetail from './pages/EntityDetail.svelte'
|
|
import Agent from './pages/Agent.svelte'
|
|
import Knowledge from './pages/Knowledge.svelte'
|
|
import Learning from './pages/Learning.svelte'
|
|
import Audit from './pages/Audit.svelte'
|
|
import { newChat } from '$lib/stores/chat'
|
|
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
|
|
import { connectionState } from '$lib/stores/events'
|
|
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 ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
|
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
|
|
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
|
|
import DatabaseIcon from '@lucide/svelte/icons/database'
|
|
import ActivityIcon from '@lucide/svelte/icons/activity'
|
|
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
|
|
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
|
import SirenIcon from '@lucide/svelte/icons/siren'
|
|
import NetworkIcon from '@lucide/svelte/icons/share-2'
|
|
import BotIcon from '@lucide/svelte/icons/bot'
|
|
import SearchIcon from '@lucide/svelte/icons/search'
|
|
import ScrollTextIcon from '@lucide/svelte/icons/scroll-text'
|
|
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
|
|
|
let page = $state('tasks')
|
|
let routeParam = $state('')
|
|
let drawerOpen = $state(false)
|
|
|
|
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
|
|
const openSignals = $derived(openSignalCount($summary))
|
|
|
|
onMount(() => {
|
|
function sync() {
|
|
const path = location.hash.slice(2) || 'tasks'
|
|
const [head, ...rest] = path.split('/')
|
|
page = head || 'tasks'
|
|
routeParam = rest.join('/')
|
|
}
|
|
sync()
|
|
window.addEventListener('hashchange', sync)
|
|
const unsubscribeCtx = subscribeContext()
|
|
|
|
return () => {
|
|
window.removeEventListener('hashchange', sync)
|
|
unsubscribeCtx()
|
|
}
|
|
})
|
|
|
|
function navigate(p: string) {
|
|
location.hash = '#/' + p
|
|
}
|
|
|
|
const navItems = [
|
|
{ id: 'overview', label: 'Overview', icon: LayoutDashboardIcon },
|
|
{ id: 'entities', label: 'Entities', icon: DatabaseIcon },
|
|
{ id: 'graph', label: 'Graph', icon: NetworkIcon },
|
|
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
|
|
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
|
|
{ id: 'events', label: 'Events', icon: ActivityIcon },
|
|
{ id: 'agent', label: 'Agent', icon: BotIcon },
|
|
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
|
|
{ id: 'learning', label: 'Learning', icon: TrendingUpIcon },
|
|
{ id: 'audit', label: 'Audit', icon: ScrollTextIcon }
|
|
]
|
|
</script>
|
|
|
|
<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>
|
|
<Sidebar.MenuItem>
|
|
<Sidebar.MenuButton isActive={page === 'tasks' || page === 'chat'} onclick={() => navigate('tasks')} tooltipContent="Tasks">
|
|
{#snippet child({ props })}
|
|
<button {...props}>
|
|
<ListTodoIcon />
|
|
<span>Tasks</span>
|
|
</button>
|
|
{/snippet}
|
|
</Sidebar.MenuButton>
|
|
</Sidebar.MenuItem>
|
|
{#each navItems as item}
|
|
<Sidebar.MenuItem>
|
|
<Sidebar.MenuButton
|
|
isActive={page === item.id}
|
|
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>
|
|
</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('tasks')}>Tasks</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}</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 === 'tasks'}
|
|
<Tasks />
|
|
{:else if page === 'entities'}
|
|
<Entities />
|
|
{:else if page === 'graph'}
|
|
<Graph />
|
|
{:else if page === 'entity' && routeParam}
|
|
<EntityDetail slug={routeParam} />
|
|
{:else if page === 'ops'}
|
|
<Ops />
|
|
{:else if page === 'signals'}
|
|
<Signals />
|
|
{:else if page === 'events'}
|
|
<Events />
|
|
{:else if page === 'agent'}
|
|
<Agent />
|
|
{:else if page === 'knowledge'}
|
|
<Knowledge />
|
|
{:else if page === 'learning'}
|
|
<Learning />
|
|
{:else if page === 'audit'}
|
|
<Audit />
|
|
{: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>
|