Files
oikos/web/src/pages/Knowledge.svelte
dtoro bd44626532
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
feat(web): floating entity-detail windows (wmkit), replacing sidebar/sheet
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>
2026-07-18 10:46:27 +02:00

178 lines
7.6 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 hasnt recorded any learnings yet — it will write them here as it solves problems.' : 'No knowledge yet.'}
</p>
{/if}
{/each}
</div>
</ScrollArea>
{/if}
</div>