Every place that showed entity detail (Knowledge Base's right sidebar, the EntitySheet drawer used by Knowledge and the chat session graph, the standalone /entity/:slug page) now opens the entity in its own floating, draggable, resizable window instead — several can be open side by side, and clicking a relation inside one opens another, building up a stack. Windows are managed by one global wmkit instance (new $lib/stores/windows.ts + $lib/components/EntityDesktop.svelte, mounted once in App.svelte), themed with the app's own card/border/ring tokens rather than wmkit's bundled themes (app.css). - Delete EntitySheet.svelte (redundant) and the KnowledgeBase resizable detail pane; row/graph-node click handlers now call openEntityWindow(slug) instead of setting local sidebar state. - SessionGraph (chat's "Scope" mini-graph): clicking a node opens its window directly instead of a click-through mini-detail panel with its own resize handle and "Full detail" button — that whole subsystem is now dead and removed. Node highlight ring is kept (still useful to see what you last opened) and now clears itself via an effect watching the shared window-manager store, so closing a window drops the highlight instead of leaving it pointing at nothing — same fix applied to Knowledge Base's row highlight. - Compact the entity-detail panel's padding (container + each DetailSection) now that it's typically viewed in a small window rather than a full-height sidebar. - Fix KnowledgeBase's browse pane losing its flex-1/min-w-0 (and thus full width) when the wrapping single-child div around it was removed along with the old detail-pane split. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
178 lines
7.6 KiB
Svelte
178 lines
7.6 KiB
Svelte
<script lang="ts">
|
||
import DOMPurify from 'dompurify'
|
||
import { searchKnowledge, fetchRecentKnowledge, type KnowledgeHit, type RecentKnowledge, type KnowledgeItem } from '$lib/api'
|
||
import * as Card from '$lib/components/ui/card'
|
||
import { Badge } from '$lib/components/ui/badge'
|
||
import { Input } from '$lib/components/ui/input'
|
||
import { Button } from '$lib/components/ui/button'
|
||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||
import { openEntityWindow } from '$lib/stores/windows'
|
||
import SearchIcon from '@lucide/svelte/icons/search'
|
||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||
import BotIcon from '@lucide/svelte/icons/bot'
|
||
|
||
let query = $state('')
|
||
let results = $state<KnowledgeHit[]>([])
|
||
let loading = $state(false)
|
||
let searched = $state(false)
|
||
|
||
let recent = $state<RecentKnowledge>({ stats: { total: 0, by_kind: {}, agent_authored: 0, last_7d: 0 }, items: [] })
|
||
let agentOnly = $state(false)
|
||
let loadingRecent = $state(true)
|
||
|
||
async function loadRecent() {
|
||
loadingRecent = true
|
||
recent = await fetchRecentKnowledge(agentOnly ? 'nomos-agent' : undefined)
|
||
loadingRecent = false
|
||
}
|
||
loadRecent()
|
||
|
||
function toggleAgentOnly() {
|
||
agentOnly = !agentOnly
|
||
loadRecent()
|
||
}
|
||
|
||
async function search() {
|
||
if (!query.trim()) { searched = false; return }
|
||
loading = true
|
||
results = await searchKnowledge(query)
|
||
loading = false
|
||
searched = true
|
||
}
|
||
|
||
function typeVariant(type: string): 'default' | 'secondary' | 'outline' {
|
||
if (type === 'runbook') return 'secondary'
|
||
if (type === 'investigation') return 'default'
|
||
return 'outline'
|
||
}
|
||
|
||
function relTime(iso: string): string {
|
||
const d = new Date(iso).getTime()
|
||
if (!d) return ''
|
||
const s = Math.round((Date.now() - d) / 1000)
|
||
if (s < 60) return 'just now'
|
||
if (s < 3600) return `${Math.floor(s / 60)}m ago`
|
||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
|
||
return `${Math.floor(s / 86400)}d ago`
|
||
}
|
||
|
||
</script>
|
||
|
||
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
||
<div class="flex items-center justify-between">
|
||
<h1 class="text-lg font-semibold">Knowledge</h1>
|
||
</div>
|
||
|
||
<!-- Learning stats: the system getting smarter, made visible -->
|
||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||
<Card.Root>
|
||
<Card.Header class="p-3">
|
||
<Card.Description class="text-xs">Total notes</Card.Description>
|
||
<Card.Title class="text-2xl">{recent.stats.total}</Card.Title>
|
||
</Card.Header>
|
||
</Card.Root>
|
||
<Card.Root class="border-primary/30 bg-primary/5">
|
||
<Card.Header class="p-3">
|
||
<Card.Description class="flex items-center gap-1 text-xs"><BotIcon class="size-3" /> Written by Nomos</Card.Description>
|
||
<Card.Title class="text-2xl text-primary">{recent.stats.agent_authored}</Card.Title>
|
||
</Card.Header>
|
||
</Card.Root>
|
||
<Card.Root class="border-success/30 bg-success/5">
|
||
<Card.Header class="p-3">
|
||
<Card.Description class="flex items-center gap-1 text-xs"><SparklesIcon class="size-3" /> Learned this week</Card.Description>
|
||
<Card.Title class="text-2xl text-success">{recent.stats.last_7d}</Card.Title>
|
||
</Card.Header>
|
||
</Card.Root>
|
||
<Card.Root>
|
||
<Card.Header class="p-3">
|
||
<Card.Description class="text-xs">Runbooks / investigations</Card.Description>
|
||
<Card.Title class="text-2xl">{(recent.stats.by_kind.runbook ?? 0)} / {(recent.stats.by_kind.investigation ?? 0)}</Card.Title>
|
||
</Card.Header>
|
||
</Card.Root>
|
||
</div>
|
||
|
||
<!-- Search -->
|
||
<form onsubmit={(e) => { e.preventDefault(); search() }} class="flex gap-2">
|
||
<div class="relative flex-1 max-w-lg">
|
||
<SearchIcon class="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input placeholder="Search documents, runbooks, investigations…" bind:value={query} class="pl-8" />
|
||
</div>
|
||
<Button type="submit" disabled={loading || !query.trim()}>{loading ? 'Searching…' : 'Search'}</Button>
|
||
{#if searched}
|
||
<Button type="button" variant="ghost" onclick={() => { query = ''; searched = false }}>Clear</Button>
|
||
{/if}
|
||
</form>
|
||
|
||
{#if searched}
|
||
<!-- Search results mode -->
|
||
<p class="text-sm text-muted-foreground">{results.length} result{results.length === 1 ? '' : 's'} for "{query}"</p>
|
||
<ScrollArea class="flex-1">
|
||
<div class="flex flex-col gap-3 pr-4">
|
||
{#each results as hit (hit.slug)}
|
||
<Card.Root class="transition-colors hover:bg-muted/50">
|
||
<Card.Header>
|
||
<div class="flex items-center gap-2">
|
||
<Card.Title class="text-sm">{hit.title}</Card.Title>
|
||
<Badge variant={typeVariant(hit.type)}>{hit.type}</Badge>
|
||
</div>
|
||
{#if hit.snippet}
|
||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized below, ts_headline only ever emits <b> -->
|
||
<Card.Description class="text-xs"
|
||
>{@html DOMPurify.sanitize(hit.snippet, { ALLOWED_TAGS: ['b'], ALLOWED_ATTR: [] })}</Card.Description
|
||
>
|
||
{/if}
|
||
{#if hit.linked_entities?.length}
|
||
<div class="mt-1 flex flex-wrap gap-1">
|
||
{#each hit.linked_entities as slug}
|
||
<button type="button" class="font-mono text-xs text-muted-foreground underline" onclick={() => openEntityWindow(slug)}>{slug}</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</Card.Header>
|
||
</Card.Root>
|
||
{:else}
|
||
{#if !loading}<p class="py-12 text-center text-muted-foreground">No results found.</p>{/if}
|
||
{/each}
|
||
</div>
|
||
</ScrollArea>
|
||
{:else}
|
||
<!-- Recently learned mode (default) -->
|
||
<div class="flex items-center justify-between">
|
||
<h2 class="text-sm font-medium text-muted-foreground">Recently learned</h2>
|
||
<Button size="sm" variant={agentOnly ? 'default' : 'outline'} class="h-7 gap-1 text-xs" onclick={toggleAgentOnly}>
|
||
<BotIcon class="size-3" /> {agentOnly ? 'Nomos only' : 'All sources'}
|
||
</Button>
|
||
</div>
|
||
<ScrollArea class="flex-1">
|
||
<div class="flex flex-col gap-2 pr-4">
|
||
{#each recent.items as it (it.slug)}
|
||
<div class="flex items-start gap-3 rounded-lg border px-3 py-2 transition-colors hover:bg-muted/40 {it.agent_authored ? 'border-primary/30 bg-primary/[0.03]' : ''}">
|
||
<div class="mt-0.5">
|
||
{#if it.agent_authored}<BotIcon class="size-4 text-primary" />{:else}<SearchIcon class="size-4 text-muted-foreground" />{/if}
|
||
</div>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<span class="text-sm font-medium">{it.title}</span>
|
||
<Badge variant={typeVariant(it.kind)} class="text-[10px]">{it.kind}</Badge>
|
||
{#if it.agent_authored}<Badge variant="outline" class="border-primary/40 text-[10px] text-primary">learned by Nomos</Badge>{/if}
|
||
</div>
|
||
{#if it.tags.length}
|
||
<div class="mt-1 flex flex-wrap gap-1">
|
||
{#each it.tags as t}<span class="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{t}</span>{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
<span class="shrink-0 text-xs text-muted-foreground">{relTime(it.updated_at)}</span>
|
||
</div>
|
||
{:else}
|
||
{#if !loadingRecent}
|
||
<p class="py-12 text-center text-sm text-muted-foreground">
|
||
{agentOnly ? 'Nomos hasn’t recorded any learnings yet — it will write them here as it solves problems.' : 'No knowledge yet.'}
|
||
</p>
|
||
{/if}
|
||
{/each}
|
||
</div>
|
||
</ScrollArea>
|
||
{/if}
|
||
</div>
|