Merge remote-tracking branch 'origin/main' into claude/coolify-oikos-comparison-d8c2d3

This commit is contained in:
2026-07-28 14:05:36 +02:00
19 changed files with 4133 additions and 269 deletions

View File

@@ -333,3 +333,92 @@ a:hover {
border-left: 1px solid var(--border);
cursor: col-resize;
}
/* Base markdown rendering — used by every {@html marked.parse(...)} output
(EntityDetailContent, the Knowledge wiki's WikiReader, and as the
foundation ChatThread's fuller "Art Nouveau" chat styling builds on top
of). Global rather than a per-component <style> block: Svelte scopes
<style> to one component, so three separate copies of this same ~50-line
ruleset had accumulated (EntityDetailContent's copy was already a
documented "can't share, Svelte scopes styles" duplicate of ChatThread's,
and WikiReader added a third when the Knowledge wiki was built). Anything
that renders sanitized markdown into an .markdown-body container gets
this for free; a component only needs its own <style> block for looks
that genuinely diverge from this baseline (see ChatThread.svelte's
trimmed-down block for the pattern: same class, only the deltas kept,
using a two-class selector so its overrides win on specificity rather
than depending on <style> injection order).
Includes explicit list-style-type — Tailwind's preflight reset (@import
'tailwindcss' above) strips it from every <ul>/<ol>, so without this,
markdown bullet/numbered lists silently render with no markers. */
.markdown-body p {
margin: 0 0 0.5rem;
}
.markdown-body p:last-child {
margin-bottom: 0;
}
.markdown-body ul,
.markdown-body ol {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.markdown-body ul {
list-style-type: disc;
}
.markdown-body ol {
list-style-type: decimal;
}
.markdown-body li {
margin-bottom: 0.125rem;
}
.markdown-body code {
background: var(--muted);
border-radius: 4px;
padding: 0.1em 0.35em;
font-family: var(--font-mono);
font-size: 0.85em;
}
.markdown-body pre {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.625rem 0.75rem;
overflow-x: auto;
margin: 0 0 0.5rem;
}
.markdown-body pre code {
background: none;
padding: 0;
font-size: 0.8125rem;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3 {
font-weight: 600;
margin: 0.75rem 0 0.375rem;
font-size: 1em;
}
.markdown-body table {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.markdown-body th,
.markdown-body td {
border: 1px solid var(--border);
padding: 0.25rem 0.5rem;
text-align: left;
}
.markdown-body blockquote {
border-left: 3px solid var(--border);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
}
.markdown-body a {
color: var(--primary);
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}

View File

@@ -675,8 +675,10 @@ export interface KnowledgeContent {
title: string
content: string
source: string
edited_by: string
tags: string[]
updated_at: string
revisions: number
}
// Full markdown body for a document/investigation/runbook entity — distinct
@@ -688,6 +690,259 @@ export async function fetchKnowledgeContent(id: string): Promise<KnowledgeConten
return res.json()
}
// ─── Knowledge wiki: write path + drift tooling ────────────────────────────
//
// Everything below this line talks to internal/httpapi/knowledge_write.go
// and knowledge_drift.go — the operator-facing CRUD surface added alongside
// the wiki redesign. Before this, the only writer was the MCP tool the agent
// uses; the web UI could search and read but never create, correct, or
// retire a note.
//
// Mutations throw KnowledgeApiError on failure instead of returning null —
// unlike the read helpers above, a write failure usually has a specific,
// user-facing reason (409 "a note with this title already exists", 400
// "content cannot be empty") that the caller needs to display, not just a
// generic "something went wrong."
// Mirrors the RFC7807 problem+json shape internal/httpapi/problem.go writes.
export class KnowledgeApiError extends Error {
status: number
detail: string
constructor(status: number, title: string, detail: string) {
super(title)
this.status = status
this.detail = detail
}
}
async function parseKnowledgeError(res: Response): Promise<never> {
let title = `request failed (${res.status})`
let detail = ''
try {
const body = await res.json()
title = body.title ?? title
detail = body.detail ?? ''
} catch {
// non-JSON error body — fall back to the generic title above
}
throw new KnowledgeApiError(res.status, title, detail)
}
export interface KnowledgeListItem {
id: string
slug: string
title: string
kind: 'document' | 'runbook' | 'investigation'
source: string
edited_by: string
tags: string[]
about: string[]
size: number
updated_at: string
created_at: string
revisions: number
}
// The full live set, body-free — backs the wiki navigator tree. Distinct
// from fetchRecentKnowledge, which caps at 200 and drives the stats/recency
// view; the tree needs every note plus linked-entity slugs for the
// group-by-entity arrangement.
// Throws KnowledgeApiError on failure rather than returning [] — an empty
// list here must mean "the collection really is empty," never "the request
// failed." Silently treating a 500/network error as [] previously left the
// whole wiki reporting "0 notes" indistinguishable from an actual outage;
// see Knowledge.svelte's loadItems for how the caller surfaces this.
export async function listKnowledge(): Promise<KnowledgeListItem[]> {
const res = await fetchWithAuth(`${API}/knowledge/list`)
if (!res.ok) return parseKnowledgeError(res)
const data = await res.json()
return data.items ?? []
}
export interface KnowledgeWriteInput {
title?: string
content?: string
kind?: 'document' | 'investigation' | 'runbook'
tags?: string[]
folder?: string
about?: string[]
}
export async function createKnowledge(
input: KnowledgeWriteInput
): Promise<{ slug: string; id: string; linked: string[] }> {
const res = await fetchWithAuth(`${API}/knowledge`, {
method: 'POST',
body: JSON.stringify(input)
})
if (!res.ok) return parseKnowledgeError(res)
return res.json()
}
// idOrSlug identifies the note; only the fields present in `input` are
// changed (undefined = leave alone), matching the PUT handler's COALESCE
// semantics — see knowledge_write.go's serveUpdateKnowledge.
// `linked` echoes back which `about` slugs actually resolved (only present
// when `input.about` was supplied) — a typo'd entity slug otherwise fails
// server-side with nothing but a log line, so the caller can diff this
// against what it sent and warn about anything that silently didn't take.
export async function updateKnowledge(
idOrSlug: string,
input: KnowledgeWriteInput
): Promise<{ linked?: string[] }> {
const res = await fetchWithAuth(`${API}/knowledge/content/${encodeURIComponent(idOrSlug)}`, {
method: 'PUT',
body: JSON.stringify(input)
})
if (!res.ok) return parseKnowledgeError(res)
return res.json()
}
// Soft delete — the note moves to the trash (fetchKnowledgeTrash) and can be
// brought back with restoreKnowledge. Never a hard, unrecoverable delete.
export async function deleteKnowledge(idOrSlug: string): Promise<void> {
const res = await fetchWithAuth(`${API}/knowledge/content/${encodeURIComponent(idOrSlug)}`, {
method: 'DELETE'
})
if (!res.ok) return parseKnowledgeError(res)
}
export async function restoreKnowledge(idOrSlug: string): Promise<void> {
const res = await fetchWithAuth(`${API}/knowledge/restore/${encodeURIComponent(idOrSlug)}`, {
method: 'POST'
})
if (!res.ok) return parseKnowledgeError(res)
}
export interface KnowledgeTrashItem {
slug: string
title: string
kind: string
deleted_by: string
deleted_at: string
}
// Throws on failure — see listKnowledge's comment on why "empty" and
// "failed" must not collapse into the same [].
export async function fetchKnowledgeTrash(): Promise<KnowledgeTrashItem[]> {
const res = await fetchWithAuth(`${API}/knowledge/trash`)
if (!res.ok) return parseKnowledgeError(res)
const data = await res.json()
return data.items ?? []
}
export interface KnowledgeRevision {
id: number
title: string
content: string
edited_by: string
tags: string[]
version_at: string
revised_at: string
}
// Newest first. Works even for a soft-deleted note — inspecting what was
// lost is exactly when history matters most (see resolveKnowledgeEntityAny
// in knowledge_write.go).
export async function fetchKnowledgeRevisions(idOrSlug: string): Promise<KnowledgeRevision[]> {
const res = await fetchWithAuth(`${API}/knowledge/revisions/${encodeURIComponent(idOrSlug)}`)
if (!res.ok) return parseKnowledgeError(res)
const data = await res.json()
return data.items ?? []
}
export interface KnowledgeTag {
tag: string
uses: number
variants: string[]
// True when the same tag is stored under more than one casing (e.g.
// "oom" / "OOM") — the tag manager badges these as needing a normalize.
split: boolean
}
export async function fetchKnowledgeTags(): Promise<KnowledgeTag[]> {
const res = await fetchWithAuth(`${API}/knowledge/tags`)
if (!res.ok) return parseKnowledgeError(res)
const data = await res.json()
return data.items ?? []
}
// Rewrites every `from` tag to `to` across all live notes. Pass several
// `from` values to merge them into one; pass a tag's own case variants to
// normalize casing.
export async function renameKnowledgeTag(from: string[], to: string): Promise<number> {
const res = await fetchWithAuth(`${API}/knowledge/tags/rename`, {
method: 'POST',
body: JSON.stringify({ from, to })
})
if (!res.ok) return parseKnowledgeError(res)
const data = await res.json()
return data.notes_updated ?? 0
}
export interface KnowledgeDuplicateMember {
slug: string
title: string
kind: string
size: number
updated_at: string
edited_by: string
}
export interface KnowledgeDuplicateCluster {
members: KnowledgeDuplicateMember[]
top_similarity: number
total_size: number
}
// Title-similarity clusters — candidates for review, never a verdict. See
// the Go handler: notes that share a naming template (e.g. the five
// "Lifecycle: <verb> a node" runbooks) can cluster here despite being
// genuinely distinct documents, so the UI must let the operator inspect
// each cluster rather than offering a blind "merge all."
export async function fetchKnowledgeDuplicates(
threshold?: number
): Promise<KnowledgeDuplicateCluster[]> {
const params = threshold ? `?threshold=${threshold}` : ''
const res = await fetchWithAuth(`${API}/knowledge/duplicates${params}`)
if (!res.ok) return parseKnowledgeError(res)
const data = await res.json()
return data.clusters ?? []
}
export interface KnowledgeOrphan {
slug: string
title: string
kind: string
edited_by: string
updated_at: string
reasons: ('untagged' | 'unlinked' | 'stale')[]
}
export async function fetchKnowledgeOrphans(
staleDays?: number
): Promise<{ items: KnowledgeOrphan[]; counts: Record<string, number> }> {
const params = staleDays ? `?stale_days=${staleDays}` : ''
const res = await fetchWithAuth(`${API}/knowledge/orphans${params}`)
if (!res.ok) return parseKnowledgeError(res)
return res.json()
}
// Folds `sources` into `target`: each source's body is appended under a
// provenance heading, tags are unioned, and the sources are soft-deleted
// (recoverable from trash, same as a plain delete).
export async function mergeKnowledge(
target: string,
sources: string[]
): Promise<{ merged: string[]; tags_added: string[] }> {
const res = await fetchWithAuth(`${API}/knowledge/merge`, {
method: 'POST',
body: JSON.stringify({ target, sources })
})
if (!res.ok) return parseKnowledgeError(res)
return res.json()
}
export async function fetchEntityEvents(
entityId: string
): Promise<import('./stores/events').OikosEvent[]> {

View File

@@ -274,7 +274,9 @@
/>
{/if}
{#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
<div
class="markdown-body prose-chat max-w-none text-sm leading-relaxed assistant-msg"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
{@html render(msg.text)}
{#if isLast && streaming}
@@ -416,50 +418,36 @@
overflow-wrap: break-word;
}
/* Prose overrides */
.prose-chat :global(p) {
margin: 0 0 0.5rem;
}
.prose-chat :global(p:last-child) {
margin-bottom: 0;
}
.prose-chat :global(ul),
.prose-chat :global(ol) {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.prose-chat :global(ul) {
list-style-type: disc;
}
.prose-chat :global(ol) {
list-style-type: decimal;
}
/* Prose overrides — deltas on top of the shared .markdown-body base
(app.css) only. The template applies both classes together
(class="markdown-body prose-chat ..."); everything below either adds a
look .markdown-body doesn't have (li::marker, the pre/blockquote
::before ornaments, hr, strong, the table-wrapper, the code-copy
button) or overrides a .markdown-body value that this "Art Nouveau"
chat treatment wants different (code/pre padding, heading size, th/td
padding, blockquote border color, link underline style). Anywhere a
value is actually overridden, the selector is
`.markdown-body.prose-chat` rather than `.prose-chat` alone —
:global() selectors from two different <style> blocks land in the same
stylesheet with no scoping to arbitrate between them, so equal
specificity would leave the winner to injection order (unreliable
across dev/build). The two-class selector's higher specificity wins
deterministically regardless. */
.prose-chat :global(li) {
margin-bottom: 0.125rem;
padding-left: 0.25rem;
}
.prose-chat :global(li::marker) {
color: var(--primary);
}
.prose-chat :global(code) {
background: var(--muted);
.markdown-body.prose-chat :global(code) {
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.15em 0.4em;
font-family: var(--font-mono);
font-size: 0.85em;
color: var(--primary);
}
.prose-chat :global(pre) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
.markdown-body.prose-chat :global(pre) {
padding: 0.75rem 0.875rem;
overflow-x: auto;
margin: 0 0 0.5rem;
position: relative;
}
.prose-chat :global(pre)::before {
@@ -473,34 +461,28 @@
opacity: 0.4;
}
.prose-chat :global(pre code) {
background: none;
padding: 0;
font-size: 0.8125rem;
color: inherit;
border: none;
}
/* Section headings — serif (Inknut) with a short accent rule. Extra top
margin separates sections; the first heading in a message doesn't. */
.prose-chat :global(h1) {
.markdown-body.prose-chat :global(h1) {
font-size: 1.15em;
font-weight: 600;
margin: 1.15rem 0 0.4rem;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(h2) {
.markdown-body.prose-chat :global(h2) {
font-size: 1.08em;
font-weight: 600;
margin: 1.15rem 0 0.4rem;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(h3) {
.markdown-body.prose-chat :global(h3) {
font-size: 1.02em;
font-weight: 600;
margin: 1.15rem 0 0.4rem;
letter-spacing: 0.01em;
position: relative;
@@ -524,11 +506,6 @@
opacity: 0.55;
}
.prose-chat :global(table) {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.prose-chat :global(.table-wrapper) {
overflow-x: auto;
margin: 0 0 0.5rem;
@@ -540,18 +517,13 @@
background: var(--muted);
font-weight: 600;
}
.prose-chat :global(th),
.prose-chat :global(td) {
border: 1px solid var(--border);
.markdown-body.prose-chat :global(th),
.markdown-body.prose-chat :global(td) {
padding: 0.3rem 0.6rem;
text-align: left;
}
.prose-chat :global(blockquote) {
.markdown-body.prose-chat :global(blockquote) {
border-left: 3px solid var(--primary);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
font-style: italic;
position: relative;
}
@@ -587,8 +559,7 @@
font-weight: 600;
}
.prose-chat :global(a) {
color: var(--primary);
.markdown-body.prose-chat :global(a) {
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;

View File

@@ -483,8 +483,10 @@
{#snippet contentContent()}
{#if ownContent}
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
<div class="prose-chat max-w-none text-xs">{@html renderMarkdown(ownContent.content)}</div>
<div class="markdown-body max-w-none text-xs">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
{@html renderMarkdown(ownContent.content)}
</div>
{:else}
<p class="text-xs text-muted-foreground">No content.</p>
{/if}
@@ -890,67 +892,3 @@
{/each}
{/if}
</div>
<style>
/* Minimal markdown styling for document/investigation/runbook content —
mirrors Chat.svelte's .prose-chat (Svelte scopes styles per-component,
so it can't be shared directly). */
.prose-chat :global(p) {
margin: 0 0 0.5rem;
}
.prose-chat :global(p:last-child) {
margin-bottom: 0;
}
.prose-chat :global(ul),
.prose-chat :global(ol) {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.prose-chat :global(li) {
margin-bottom: 0.125rem;
}
.prose-chat :global(code) {
background: var(--muted);
border-radius: 4px;
padding: 0.1em 0.35em;
font-family: var(--font-mono);
font-size: 0.85em;
}
.prose-chat :global(pre) {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.625rem 0.75rem;
overflow-x: auto;
margin: 0 0 0.5rem;
}
.prose-chat :global(pre code) {
background: none;
padding: 0;
font-size: 0.8125rem;
}
.prose-chat :global(h1),
.prose-chat :global(h2),
.prose-chat :global(h3) {
font-weight: 600;
margin: 0.75rem 0 0.375rem;
font-size: 1em;
}
.prose-chat :global(table) {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.prose-chat :global(th),
.prose-chat :global(td) {
border: 1px solid var(--border);
padding: 0.25rem 0.5rem;
text-align: left;
}
.prose-chat :global(blockquote) {
border-left: 3px solid var(--border);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
}
</style>

View File

@@ -0,0 +1,626 @@
<script lang="ts">
// Maintenance view for the knowledge base's own drift — duplicates, tag
// casing splits, orphaned notes, and the trash. Surfaced as its own mode
// rather than folded into the main three-pane view because none of this
// is "browse a note," it's "audit the collection," and mixing the two
// would clutter the read/edit flow with tools most visits don't need.
//
// Every action here (merge, rename, restore) is deliberately one click
// away from a review step, never automatic — see fetchKnowledgeDuplicates'
// own doc comment on why title-similarity clustering can't be trusted as
// a verdict (the five "Lifecycle: <verb> a node" runbooks cluster despite
// being genuinely distinct documents).
import {
fetchKnowledgeDuplicates,
fetchKnowledgeTags,
fetchKnowledgeOrphans,
fetchKnowledgeTrash,
renameKnowledgeTag,
mergeKnowledge,
restoreKnowledge,
KnowledgeApiError,
type KnowledgeDuplicateCluster,
type KnowledgeTag,
type KnowledgeOrphan,
type KnowledgeTrashItem
} from '$lib/api'
import * as Tabs from '$lib/components/ui/tabs'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import { Badge } from '$lib/components/ui/badge'
import { Skeleton } from '$lib/components/ui/skeleton'
import { toast } from 'svelte-sonner'
import { kindMeta } from './kinds'
import { relativeTime } from '$lib/utils'
import CopyIcon from '@lucide/svelte/icons/copy'
import TagIcon from '@lucide/svelte/icons/tag'
import GhostIcon from '@lucide/svelte/icons/ghost'
import Trash2Icon from '@lucide/svelte/icons/trash-2'
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
import CheckIcon from '@lucide/svelte/icons/check'
let {
onSelect,
onChanged
}: {
onSelect: (slug: string) => void
// Fired after any mutation (merge, tag rename, restore) so the parent's
// note list — which this view reads a filtered copy of, indirectly —
// stays in sync.
onChanged: () => void
} = $props()
let tab = $state<'duplicates' | 'tags' | 'orphans' | 'trash'>('duplicates')
// Shared across every loader/action below: the read helpers in api.ts now
// throw KnowledgeApiError on a failed request instead of quietly returning
// an empty list, so a real outage can't be mistaken for "nothing to
// clean up" — see api.ts's comment on listKnowledge for the same fix
// applied to the main note list.
function errMsg(e: unknown): string {
return e instanceof KnowledgeApiError ? e.message : 'Request failed.'
}
// ─── Duplicates ───────────────────────────────────────────────────────
let clusters = $state<KnowledgeDuplicateCluster[] | null>(null)
let duplicatesError = $state('')
// Per cluster (indexed by the cluster's first member slug — stable across
// a reload since clusters are keyed by content, not array position):
// which slug is the merge target and which sources are checked.
let mergeTarget = $state<Record<string, string>>({})
let mergeSources = $state<Record<string, Set<string>>>({})
let merging = $state<string | null>(null)
let mergeError = $state('')
async function loadDuplicates(): Promise<void> {
clusters = null
duplicatesError = ''
try {
const result = await fetchKnowledgeDuplicates()
clusters = result
const targets: Record<string, string> = {}
const sources: Record<string, Set<string>> = {}
for (const c of result) {
const key = c.members[0].slug
targets[key] = c.members[0].slug // newest first — see the Go handler's sort
sources[key] = new Set(c.members.slice(1).map((m) => m.slug))
}
mergeTarget = targets
mergeSources = sources
} catch (e) {
duplicatesError = errMsg(e)
clusters = [] // clear the loading skeleton — the error message above explains the empty state
}
}
// A merge target switch leaves the PREVIOUS target unchecked (it's not in
// `sources` since it used to be excluded as "the target"), so recompute
// the whole source set relative to the new target rather than leaving it
// stale — otherwise the old target silently drops out of the merge
// instead of folding in like every other member.
function setMergeTarget(clusterKey: string, newTarget: string, allSlugs: string[]): void {
mergeTarget = { ...mergeTarget, [clusterKey]: newTarget }
mergeSources = {
...mergeSources,
[clusterKey]: new Set(allSlugs.filter((s) => s !== newTarget))
}
}
function toggleSource(clusterKey: string, slug: string): void {
const set = new Set(mergeSources[clusterKey])
if (set.has(slug)) set.delete(slug)
else set.add(slug)
mergeSources = { ...mergeSources, [clusterKey]: set }
}
async function doMerge(clusterKey: string): Promise<void> {
const target = mergeTarget[clusterKey]
const sources = [...(mergeSources[clusterKey] ?? [])]
if (!target || sources.length === 0) return
merging = clusterKey
mergeError = ''
try {
const result = await mergeKnowledge(target, sources)
toast.success(`Merged ${result.merged.length} note${result.merged.length === 1 ? '' : 's'}`)
onChanged()
await loadDuplicates()
} catch (e) {
mergeError = errMsg(e)
} finally {
merging = null
}
}
// ─── Tags ─────────────────────────────────────────────────────────────
let tags = $state<KnowledgeTag[] | null>(null)
let tagsError = $state('')
let renaming = $state<string | null>(null)
let renameDraft = $state('')
let renameBusy = $state(false)
async function loadTags(): Promise<void> {
tags = null
tagsError = ''
try {
tags = await fetchKnowledgeTags()
} catch (e) {
tagsError = errMsg(e)
tags = []
}
}
async function normalize(t: KnowledgeTag): Promise<void> {
renameBusy = true
try {
const n = await renameKnowledgeTag(t.variants, t.tag)
toast.success(`Normalized "${t.tag}" across ${n} note${n === 1 ? '' : 's'}`)
onChanged()
await loadTags()
} catch (e) {
toast.error(errMsg(e))
} finally {
renameBusy = false
}
}
function startRename(t: KnowledgeTag): void {
renaming = t.tag
renameDraft = t.tag
}
async function confirmRename(t: KnowledgeTag): Promise<void> {
const to = renameDraft.trim().toLowerCase()
if (!to || to === t.tag) {
renaming = null
return
}
renameBusy = true
try {
const n = await renameKnowledgeTag(t.variants, to)
toast.success(`Renamed "${t.tag}" to "${to}" across ${n} note${n === 1 ? '' : 's'}`)
onChanged()
await loadTags()
renaming = null
} catch (e) {
// Leave the rename input open on failure — the operator's typed value
// (and their reason for changing it) shouldn't vanish along with the
// error, forcing them to retype it to try again.
toast.error(errMsg(e))
} finally {
renameBusy = false
}
}
// ─── Orphans ──────────────────────────────────────────────────────────
let orphans = $state<KnowledgeOrphan[] | null>(null)
let orphanCounts = $state<Record<string, number>>({})
let orphansError = $state('')
async function loadOrphans(): Promise<void> {
orphans = null
orphansError = ''
try {
const result = await fetchKnowledgeOrphans()
orphans = result.items
orphanCounts = result.counts
} catch (e) {
orphansError = errMsg(e)
orphans = []
}
}
// ─── Trash ────────────────────────────────────────────────────────────
let trash = $state<KnowledgeTrashItem[] | null>(null)
let trashError = $state('')
let restoring = $state<string | null>(null)
async function loadTrash(): Promise<void> {
trash = null
trashError = ''
try {
trash = await fetchKnowledgeTrash()
} catch (e) {
trashError = errMsg(e)
trash = []
}
}
async function doRestore(slug: string): Promise<void> {
restoring = slug
try {
await restoreKnowledge(slug)
toast.success('Note restored')
onChanged()
await loadTrash()
} catch (e) {
toast.error(errMsg(e))
} finally {
restoring = null
}
}
function activate(t: typeof tab): void {
tab = t
if (t === 'duplicates' && clusters === null) loadDuplicates()
else if (t === 'tags' && tags === null) loadTags()
else if (t === 'orphans' && orphans === null) loadOrphans()
else if (t === 'trash' && trash === null) loadTrash()
}
// Initial tab's data.
loadDuplicates()
</script>
<div class="flex h-full flex-col gap-2">
<Tabs.Root bind:value={tab} class="flex min-h-0 flex-1 flex-col">
<Tabs.List class="h-8 w-fit">
<Tabs.Trigger value="duplicates" class="gap-1 text-xs" onclick={() => activate('duplicates')}>
<CopyIcon class="size-3.5" /> Duplicates
</Tabs.Trigger>
<Tabs.Trigger value="tags" class="gap-1 text-xs" onclick={() => activate('tags')}>
<TagIcon class="size-3.5" /> Tags
</Tabs.Trigger>
<Tabs.Trigger value="orphans" class="gap-1 text-xs" onclick={() => activate('orphans')}>
<GhostIcon class="size-3.5" /> Orphans
</Tabs.Trigger>
<Tabs.Trigger value="trash" class="gap-1 text-xs" onclick={() => activate('trash')}>
<Trash2Icon class="size-3.5" /> Trash
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="duplicates" class="min-h-0 flex-1 overflow-y-auto pt-2">
<p class="mb-2 text-xs text-muted-foreground">
Notes with near-identical titles, grouped for review — not a verdict. Pick a target and the
sources to fold into it; sources are soft-deleted afterward and stay recoverable from Trash.
</p>
{#if mergeError}
<p
class="mb-2 rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
>
{mergeError}
</p>
{/if}
{#if duplicatesError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{duplicatesError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadDuplicates}
>Retry</Button
>
</p>
{/if}
{#if clusters === null}
<div class="flex flex-col gap-3">
{#each Array(2) as _, ci (ci)}
<div class="overflow-hidden rounded-lg border">
<div
class="flex items-center justify-between gap-2 border-b bg-muted/30 px-2.5 py-1.5"
>
<Skeleton class="h-3 w-28" />
<Skeleton class="h-6 w-28" />
</div>
<div class="flex flex-col gap-2 p-2.5">
{#each Array(ci === 0 ? 3 : 2) as _, ri (ri)}
<Skeleton class="h-3.5" style="width: {70 - ri * 10}%" />
{/each}
</div>
</div>
{/each}
</div>
{:else if clusters.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">No likely duplicates found.</p>
{:else}
<div class="flex flex-col gap-3">
{#each clusters as c (c.members[0].slug)}
{@const key = c.members[0].slug}
{@const sourceCount = mergeSources[key]?.size ?? 0}
<div class="overflow-hidden rounded-lg border">
<div
class="flex items-center justify-between gap-2 border-b bg-muted/30 px-2.5 py-1.5"
>
<div class="flex min-w-0 items-center gap-2 text-xs">
<span class="font-medium">{c.members.length} similar notes</span>
<!-- Similarity as a meter rather than only a number: it's a
ratio, and the bar makes a 93% pileup visibly different
from a borderline 61% at a glance down a long list. -->
<span
class="hidden h-1 w-12 shrink-0 overflow-hidden rounded-full bg-primary/15 sm:block"
title="{(c.top_similarity * 100).toFixed(0)}% title similarity"
>
<span
class="block h-full rounded-full bg-primary"
style="width: {c.top_similarity * 100}%"
></span>
</span>
<span class="shrink-0 tabular-nums text-muted-foreground"
>{(c.top_similarity * 100).toFixed(0)}%</span
>
</div>
<Button
size="sm"
class="h-6 shrink-0 gap-1 text-xs"
disabled={merging === key || sourceCount === 0}
onclick={() => doMerge(key)}
>
{merging === key ? 'Merging…' : `Merge ${sourceCount} into target`}
</Button>
</div>
<!-- Two bare inputs per row read as "…what do these do?", so
name them once per cluster. An inline legend rather than
column headers: the controls are 14px wide and the words
are not, so headers sized to the columns just collide. -->
<p class="flex items-center gap-3 px-2.5 pt-2 text-[10px] text-muted-foreground">
<span class="flex items-center gap-1">
<span
class="inline-block size-2 rounded-full ring-1 ring-muted-foreground/60"
aria-hidden="true"
></span> keep as target
</span>
<span class="flex items-center gap-1">
<span
class="inline-block size-2 rounded-[2px] ring-1 ring-muted-foreground/60"
aria-hidden="true"
></span> fold into it
</span>
</p>
<div class="flex flex-col p-1">
{#each c.members as m (m.slug)}
{@const isTarget = mergeTarget[key] === m.slug}
{@const Icon = kindMeta(m.kind).icon}
<label
class="flex items-center gap-2 rounded px-1.5 py-1 text-xs {isTarget
? 'bg-primary/5'
: 'hover:bg-muted/40'}"
>
<input
type="radio"
class="size-3.5 shrink-0 accent-[var(--primary)]"
name="target-{key}"
aria-label="Keep &quot;{m.title}&quot; as the merge target"
checked={isTarget}
onchange={() =>
setMergeTarget(
key,
m.slug,
c.members.map((mm) => mm.slug)
)}
/>
<input
type="checkbox"
class="size-3.5 shrink-0 accent-[var(--primary)]"
aria-label="Fold &quot;{m.title}&quot; into the target"
disabled={isTarget}
checked={!isTarget && (mergeSources[key]?.has(m.slug) ?? false)}
onchange={() => toggleSource(key, m.slug)}
/>
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
<button
type="button"
class="min-w-0 flex-1 truncate text-left hover:underline {isTarget
? 'font-medium text-primary'
: ''}"
onclick={() => onSelect(m.slug)}
>
{m.title}
</button>
<span class="shrink-0 text-[10px] text-muted-foreground"
>{relativeTime(m.updated_at)}</span
>
{#if isTarget}
<Badge
variant="outline"
class="shrink-0 border-primary/40 text-[9px] text-primary">target</Badge
>
{/if}
</label>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="tags" class="min-h-0 flex-1 overflow-y-auto pt-2">
{#if tagsError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{tagsError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadTags}>Retry</Button>
</p>
{/if}
{#if tags === null}
<table class="w-full text-xs">
<thead>
<tr class="border-b text-left text-muted-foreground">
<th class="py-1 font-normal">Tag</th>
<th class="py-1 font-normal" colspan="2">Uses</th>
<th class="py-1 font-normal">Variants</th>
<th class="py-1"></th>
</tr>
</thead>
<tbody>
{#each Array(10) as _, i (i)}
<tr class="border-b border-border/50">
<td class="w-32 py-1.5 pr-2"
><Skeleton class="h-3" style="width: {60 - i * 3}%" /></td
>
<td class="w-8 py-1.5 pr-1"><Skeleton class="ml-auto h-3 w-4" /></td>
<td class="w-24 py-1.5 pr-3">
<Skeleton class="h-1 rounded-full" style="width: {90 - i * 8}%" />
</td>
<td class="py-1.5 pr-2"><Skeleton class="h-3 w-6" /></td>
<td class="py-1.5"></td>
</tr>
{/each}
</tbody>
</table>
{:else}
{@const maxUses = Math.max(1, ...tags.map((t) => t.uses))}
<table class="w-full text-xs">
<thead>
<tr class="border-b text-left text-muted-foreground">
<th class="py-1 font-normal">Tag</th>
<th class="py-1 font-normal" colspan="2">Uses</th>
<th class="py-1 font-normal">Variants</th>
<th class="py-1"></th>
</tr>
</thead>
<tbody>
{#each tags as t (t.tag)}
<tr class="border-b border-border/50">
<td class="py-1 pr-2">
{#if renaming === t.tag}
<div class="flex items-center gap-1">
<Input bind:value={renameDraft} class="h-6 w-32 text-xs" />
<Button
size="sm"
class="h-6 px-1.5"
disabled={renameBusy}
onclick={() => confirmRename(t)}
>
<CheckIcon class="size-3" />
</Button>
</div>
{:else}
<button
type="button"
class="font-mono hover:underline"
onclick={() => startRename(t)}
>
{t.tag}
</button>
{/if}
</td>
<!-- tabular-nums here (unlike the overview's standalone
figures): these are a column that has to line up. -->
<td class="w-8 py-1 pr-1 text-right tabular-nums">{t.uses}</td>
<td class="w-24 py-1 pr-3">
<!-- Magnitude, so: one hue, length-encoded, scaled to the
most-used tag. Recessive by design — it's a reading aid
down the column, not the subject of the table. -->
<span class="block h-1 overflow-hidden rounded-full bg-primary/10">
<span
class="block h-full rounded-full bg-primary/60"
style="width: {(t.uses / maxUses) * 100}%"
></span>
</span>
</td>
<td class="py-1 pr-2">
{#if t.split}
<span class="text-destructive">{t.variants.join(', ')}</span>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</td>
<td class="py-1 text-right">
{#if t.split}
<Button
size="sm"
variant="outline"
class="h-6 text-xs"
disabled={renameBusy}
onclick={() => normalize(t)}
>
Normalize
</Button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Tabs.Content>
<Tabs.Content value="orphans" class="min-h-0 flex-1 overflow-y-auto pt-2">
<p class="mb-2 text-xs text-muted-foreground">
Notes untagged, unlinked to any entity, or untouched for 90+ days — invisible to most
navigation paths and easy to lose track of.
{#if orphanCounts.untagged || orphanCounts.unlinked || orphanCounts.stale}
({orphanCounts.untagged ?? 0} untagged · {orphanCounts.unlinked ?? 0} unlinked · {orphanCounts.stale ??
0} stale)
{/if}
</p>
{#if orphansError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{orphansError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadOrphans}
>Retry</Button
>
</p>
{/if}
{#if orphans === null}
<div class="flex flex-col gap-0.5">
{#each Array(7) as _, i (i)}
<div class="flex items-center gap-2 px-1.5 py-1.5">
<Skeleton class="h-3.5 flex-1" style="max-width: {60 - (i % 4) * 8}%" />
<Skeleton class="h-4 w-14 shrink-0 rounded-full" />
<Skeleton class="h-3 w-10 shrink-0" />
</div>
{/each}
</div>
{:else if orphans.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">Nothing orphaned.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each orphans as o (o.slug)}
<button
type="button"
class="flex items-center gap-2 rounded px-1.5 py-1 text-left text-xs hover:bg-muted/40"
onclick={() => onSelect(o.slug)}
>
<span class="min-w-0 flex-1 truncate">{o.title}</span>
{#each o.reasons as r (r)}<Badge variant="outline" class="shrink-0 text-[9px]"
>{r}</Badge
>{/each}
<span class="shrink-0 text-[10px] text-muted-foreground"
>{relativeTime(o.updated_at)}</span
>
</button>
{/each}
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="trash" class="min-h-0 flex-1 overflow-y-auto pt-2">
{#if trashError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{trashError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadTrash}>Retry</Button>
</p>
{/if}
{#if trash === null}
<div class="flex flex-col gap-0.5">
{#each Array(4) as _, i (i)}
<div class="flex items-center gap-2 px-1.5 py-1.5">
<Skeleton class="h-3.5 flex-1" style="max-width: {55 - i * 6}%" />
<Skeleton class="h-3 w-32 shrink-0" />
<Skeleton class="h-6 w-16 shrink-0" />
</div>
{/each}
</div>
{:else if trash.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">Trash is empty.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each trash as t (t.slug)}
<div class="flex items-center gap-2 rounded px-1.5 py-1 text-xs hover:bg-muted/40">
<span class="min-w-0 flex-1 truncate">{t.title}</span>
<span class="shrink-0 text-[10px] text-muted-foreground">
deleted {relativeTime(t.deleted_at)} by {t.deleted_by || 'unknown'}
</span>
<Button
size="sm"
variant="outline"
class="h-6 shrink-0 gap-1 text-xs"
disabled={restoring === t.slug}
onclick={() => doRestore(t.slug)}
>
<RotateCcwIcon class="size-3" /> Restore
</Button>
</div>
{/each}
</div>
{/if}
</Tabs.Content>
</Tabs.Root>
</div>

View File

@@ -0,0 +1,124 @@
<script lang="ts">
// Right pane: the discovery half of the wiki. From any note you can walk
// to the entity it's about, and from there sideways to every other note
// that concerns the same entity or shares a tag — this is what makes the
// knowledge base a graph to browse rather than a flat list to scroll.
//
// "Related" and "tag neighbours" are derived client-side from the list
// already loaded by Knowledge.svelte (KnowledgeListItem carries `about`
// and `tags`), not a separate endpoint — with ~100 notes total, filtering
// an in-memory array is cheaper and simpler than a bespoke backlinks
// query, and it's exactly the same data WikiTree's group-by-entity/tag
// modes already use.
import type { KnowledgeListItem } from '$lib/api'
import { openEntityWindow } from '$lib/stores/windows'
import DetailSection from '$lib/components/DetailSection.svelte'
import { kindMeta } from './kinds'
import LinkIcon from '@lucide/svelte/icons/link'
let {
item,
allItems,
onSelect
}: {
item: KnowledgeListItem | null
allItems: KnowledgeListItem[]
onSelect: (slug: string) => void
} = $props()
const related = $derived.by(() => {
if (!item || item.about.length === 0) return []
const aboutSet = new Set(item.about)
return allItems
.filter((it) => it.slug !== item.slug && it.about.some((s) => aboutSet.has(s)))
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
})
const tagNeighbours = $derived.by(() => {
if (!item || item.tags.length === 0) return []
const tagSet = new Set(item.tags)
return allItems
.filter((it) => it.slug !== item.slug && it.tags.some((t) => tagSet.has(t)))
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
.slice(0, 20) // common tags (e.g. "backup") can otherwise pull in most of the KB
})
</script>
<div class="flex h-full flex-col gap-2 overflow-y-auto pr-1">
{#if !item}
<p class="py-8 text-center text-xs text-muted-foreground">Nothing selected.</p>
{:else}
<DetailSection title="About" count={item.about.length} defaultOpen={true}>
{#if item.about.length === 0}
<p class="text-xs text-muted-foreground">Not linked to any entity.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each item.about as slug (slug)}
<button
type="button"
class="flex items-center gap-1.5 rounded px-1 py-0.5 text-left font-mono text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground"
onclick={() => openEntityWindow(slug)}
>
<LinkIcon class="size-3 shrink-0" />
<span class="truncate">{slug}</span>
</button>
{/each}
</div>
{/if}
</DetailSection>
<DetailSection
title="Also about these entities"
count={related.length}
defaultOpen={related.length > 0}
>
{#if related.length === 0}
<p class="text-xs text-muted-foreground">No other notes share a linked entity.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each related as it (it.slug)}
{@const Icon = kindMeta(it.kind).icon}
<button
type="button"
class="group flex items-center gap-1.5 rounded px-1 py-1 text-left text-xs hover:bg-muted/50"
onclick={() => onSelect(it.slug)}
title={it.title}
>
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
<span class="min-w-0 flex-1 truncate group-hover:text-primary">{it.title}</span>
</button>
{/each}
</div>
{/if}
</DetailSection>
<!-- Only auto-open a *tight* neighbour set. A generic tag like "container"
is on 20 notes, and expanding all of those by default buries the
stronger entity-based links above it under a wall of weak matches;
a handful of shared-tag notes is a real cluster worth surfacing. -->
<DetailSection
title="Tag neighbours"
count={tagNeighbours.length}
defaultOpen={related.length === 0 && tagNeighbours.length > 0 && tagNeighbours.length <= 6}
>
{#if tagNeighbours.length === 0}
<p class="text-xs text-muted-foreground">No other notes share a tag.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each tagNeighbours as it (it.slug)}
{@const Icon = kindMeta(it.kind).icon}
<button
type="button"
class="group flex items-center gap-1.5 rounded px-1 py-1 text-left text-xs hover:bg-muted/50"
onclick={() => onSelect(it.slug)}
title={it.title}
>
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
<span class="min-w-0 flex-1 truncate group-hover:text-primary">{it.title}</span>
</button>
{/each}
</div>
{/if}
</DetailSection>
{/if}
</div>

View File

@@ -0,0 +1,139 @@
<script lang="ts">
// "New note" dialog — the create half of the wiki. A plain toggle group for
// kind (document/investigation/runbook) rather than the Select primitive:
// three fixed, always-visible options don't need a popover, and this
// mirrors the same toggle-group pattern WikiTree already uses for its
// group-by switch.
import { createKnowledge, KnowledgeApiError } from '$lib/api'
import * as Dialog from '$lib/components/ui/dialog'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import { Textarea } from '$lib/components/ui/textarea'
let {
open = $bindable(false),
onCreated
}: {
open: boolean
onCreated: (slug: string) => void
} = $props()
const KINDS = ['document', 'investigation', 'runbook'] as const
type Kind = (typeof KINDS)[number]
let title = $state('')
let kind = $state<Kind>('document')
let folder = $state('')
let tags = $state('')
let content = $state('')
let saving = $state(false)
let error = $state('')
function reset(): void {
title = ''
kind = 'document'
folder = ''
tags = ''
content = ''
error = ''
}
async function submit(): Promise<void> {
if (!title.trim() || !content.trim()) {
error = 'Title and content are required.'
return
}
saving = true
error = ''
try {
const result = await createKnowledge({
title: title.trim(),
content: content.trim(),
kind,
folder: folder.trim() || undefined,
tags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean)
})
onCreated(result.slug)
open = false
reset()
} catch (e) {
error =
e instanceof KnowledgeApiError
? `${e.message}${e.detail ? ` — ${e.detail}` : ''}`
: 'Create failed.'
} finally {
saving = false
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title>New knowledge note</Dialog.Title>
</Dialog.Header>
<div class="flex flex-col gap-3">
{#if error}
<p
class="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
>
{error}
</p>
{/if}
<label class="flex flex-col gap-1 text-xs text-muted-foreground" for="new-note-title">
Title
<Input id="new-note-title" bind:value={title} placeholder="Short, specific, searchable" />
</label>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">Type</span>
<div class="inline-flex overflow-hidden rounded-md border">
{#each KINDS as k (k)}
<button
type="button"
class="px-2 py-1 text-xs {kind === k
? 'bg-secondary text-secondary-foreground'
: 'hover:bg-muted/50'}"
onclick={() => (kind = k)}
>
{k}
</button>
{/each}
</div>
</div>
<label class="flex flex-col gap-1 text-xs text-muted-foreground" for="new-note-folder">
Folder <span class="text-muted-foreground/70">(optional — defaults to "operator")</span>
<Input
id="new-note-folder"
bind:value={folder}
placeholder="e.g. containers, infrastructure"
/>
</label>
<label class="flex flex-col gap-1 text-xs text-muted-foreground" for="new-note-tags">
Tags <span class="text-muted-foreground/70">(comma-separated, optional)</span>
<Input id="new-note-tags" bind:value={tags} placeholder="oom, rclone, gotcha" />
</label>
<label class="flex flex-col gap-1 text-xs text-muted-foreground" for="new-note-content">
Content (markdown)
<Textarea
id="new-note-content"
bind:value={content}
class="min-h-[160px] font-mono text-xs"
/>
</label>
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (open = false)} disabled={saving}>Cancel</Button>
<Button onclick={submit} disabled={saving}>{saving ? 'Creating…' : 'Create'}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,201 @@
<script lang="ts">
// The reader pane's resting state — what you see every time the app opens
// and nothing is selected yet.
//
// This used to be the sentence "Select a note, or create a new one."
// centred in an otherwise empty 56%-width pane: the single most-seen screen
// in the app doing no work at all. It's now the landing view, and it also
// restores the collection-level numbers the wiki redesign dropped (the old
// stats-only Knowledge page led with them, and they were the one thing that
// page did well — "the system is getting smarter" is only visible in
// aggregate).
//
// Every figure is derived from the `items` array the parent already loaded
// for the tree, so this panel costs no extra request.
import type { KnowledgeListItem } from '$lib/api'
import { kindMeta, isAgentAuthored } from './kinds'
import { relativeTime } from '$lib/utils'
import BotIcon from '@lucide/svelte/icons/bot'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import ClockIcon from '@lucide/svelte/icons/clock'
import HashIcon from '@lucide/svelte/icons/hash'
import PlusIcon from '@lucide/svelte/icons/plus'
import { Button } from '$lib/components/ui/button'
let {
items,
onSelect,
onNew
}: {
items: KnowledgeListItem[]
onSelect: (slug: string) => void
onNew: () => void
} = $props()
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
// Postgres renders timestamptz as "2026-07-26 10:50:53.475644+00" — a space
// instead of ISO-8601's 'T', and a bare two-digit offset. V8 happens to
// accept that verbatim, but Safari's parser requires the 'T' AND an offset
// of 'Z' or ±HH:MM, so both have to be normalised together: swapping only
// the separator yields "…475644+00", which is invalid ISO and parses to NaN
// *everywhere* — strictly worse than leaving the string alone.
function parseTimestamp(raw: string): number {
return Date.parse(raw.replace(' ', 'T').replace(/([+-]\d{2})$/, '$1:00'))
}
const stats = $derived.by(() => {
const now = Date.now()
let agent = 0
let lastWeek = 0
const byKind = new Map<string, number>()
const tagCounts = new Map<string, number>()
for (const it of items) {
if (isAgentAuthored(it.edited_by)) agent++
const ts = parseTimestamp(it.updated_at)
if (!Number.isNaN(ts) && now - ts < WEEK_MS) lastWeek++
byKind.set(it.kind, (byKind.get(it.kind) ?? 0) + 1)
for (const t of it.tags) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1)
}
return {
total: items.length,
agent,
lastWeek,
byKind,
topTags: [...tagCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
}
})
// Share of the collection the agent wrote — the "is this thing actually
// learning" number, and the only ratio here worth a meter rather than
// another tile.
const agentShare = $derived(stats.total === 0 ? 0 : Math.round((stats.agent / stats.total) * 100))
const recent = $derived(
[...items].sort((a, b) => b.updated_at.localeCompare(a.updated_at)).slice(0, 6)
)
// Kinds in a fixed order so the row doesn't reshuffle as counts change.
const KIND_ORDER = ['runbook', 'investigation', 'document'] as const
</script>
<div class="mx-auto flex h-full w-full max-w-2xl flex-col gap-7 overflow-y-auto px-1 py-6">
{#if stats.total === 0}
<!-- Genuinely empty collection (not a failed load — the parent handles
that case before rendering this component). -->
<div class="flex flex-1 flex-col items-center justify-center gap-3 text-center">
<h2 class="text-lg font-semibold">Nothing here yet</h2>
<p class="max-w-sm text-sm text-muted-foreground">
The knowledge base is empty. Write the first note, or let Nomos record what it learns as it
works.
</p>
<Button size="sm" class="gap-1.5" onclick={onNew}>
<PlusIcon class="size-3.5" /> New note
</Button>
</div>
{:else}
<!-- Hero: the one number the view leads with. Sans, not the Inknut
heading face — a serif at display size reads as decoration rather
than data. Proportional figures (no tabular-nums): this is a
standalone value, not a column that has to align. -->
<div>
<h2 class="text-sm font-medium tracking-wide text-muted-foreground uppercase">
Knowledge base
</h2>
<div class="mt-1 flex items-baseline gap-2.5">
<span class="font-sans text-5xl leading-none font-semibold">{stats.total}</span>
<span class="text-sm text-muted-foreground">notes</span>
</div>
</div>
<!-- KPI row. Hairline dividers rather than boxed cards: at four items the
boxes were doing more visual work than the numbers inside them. -->
<div class="grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-border/60 sm:grid-cols-4">
{#each KIND_ORDER as k (k)}
{@const meta = kindMeta(k)}
{@const Icon = meta.icon}
<div class="flex flex-col gap-1 bg-card px-3 py-2.5">
<span class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Icon class="size-3.5" />
{meta.plural}
</span>
<span class="text-xl font-semibold">{stats.byKind.get(k) ?? 0}</span>
</div>
{/each}
<div class="flex flex-col gap-1 bg-card px-3 py-2.5">
<span class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<SparklesIcon class="size-3.5" /> this week
</span>
<span class="text-xl font-semibold">{stats.lastWeek}</span>
</div>
</div>
<!-- Meter: one ratio, one hue. Track is a lighter step of the fill's own
ramp so the whole bar reads as a single scale. -->
<div class="flex flex-col gap-1.5">
<div class="flex items-baseline justify-between text-xs">
<span class="flex items-center gap-1.5 text-muted-foreground">
<BotIcon class="size-3.5" /> Written by Nomos
</span>
<span class="text-muted-foreground">
<span class="font-semibold text-foreground">{stats.agent}</span> of {stats.total} · {agentShare}%
</span>
</div>
<div
class="h-1.5 overflow-hidden rounded-full bg-primary/15"
role="meter"
aria-valuenow={agentShare}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Share of notes written by Nomos"
>
<div class="h-full rounded-full bg-primary" style="width: {agentShare}%"></div>
</div>
</div>
<div class="flex flex-col gap-2">
<h3 class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<ClockIcon class="size-3.5" /> Recently updated
</h3>
<div class="flex flex-col">
{#each recent as it (it.slug)}
{@const Icon = kindMeta(it.kind).icon}
<button
type="button"
class="group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/50"
onclick={() => onSelect(it.slug)}
>
<Icon class="size-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-sm group-hover:text-primary">{it.title}</span>
{#if isAgentAuthored(it.edited_by)}
<BotIcon class="size-3 shrink-0 text-muted-foreground" />
{/if}
<span class="shrink-0 text-[11px] text-muted-foreground"
>{relativeTime(it.updated_at)}</span
>
</button>
{/each}
</div>
</div>
{#if stats.topTags.length > 0}
<div class="flex flex-col gap-2">
<h3 class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<HashIcon class="size-3.5" /> Busiest tags
</h3>
<div class="flex flex-wrap gap-1.5">
{#each stats.topTags as [tag, count] (tag)}
<span
class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground"
>
{tag}
<span class="text-foreground/70 tabular-nums">{count}</span>
</span>
{/each}
</div>
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,119 @@
<script lang="ts">
// Cmd/Ctrl+K quick-open over every note title — the fast path once you
// already know roughly what you're looking for, as opposed to WikiTree's
// browse-by-group path for when you don't. Built on the Dialog primitive
// + a plain filtered list rather than shadcn-svelte's `command` component:
// that component's interactive CLI installer couldn't be driven
// non-interactively in this environment (it prompts to resolve overlapping
// dependency files), and re-deriving the same arrow-key/Enter list nav by
// hand here is a small amount of code for something this self-contained.
import type { KnowledgeListItem } from '$lib/api'
import * as Dialog from '$lib/components/ui/dialog'
import { Input } from '$lib/components/ui/input'
import StatusBadge from '$lib/components/StatusBadge.svelte'
import { onDestroy } from 'svelte'
let {
open = $bindable(false),
items,
onSelect
}: {
open: boolean
items: KnowledgeListItem[]
onSelect: (slug: string) => void
} = $props()
let query = $state('')
let activeIndex = $state(0)
let inputEl = $state<HTMLInputElement | null>(null)
const results = $derived.by(() => {
const q = query.trim().toLowerCase()
const pool = q
? items.filter(
(it) =>
it.title.toLowerCase().includes(q) ||
it.slug.toLowerCase().includes(q) ||
it.tags.some((t) => t.toLowerCase().includes(q))
)
: items
return pool.slice(0, 30) // 102 notes total — cap the render, not the match
})
$effect(() => {
void results // dependency only — re-run when the result set changes
activeIndex = 0
})
// Reset on every open so quick-open never remembers the last search, and
// focus the input once the dialog has actually mounted it.
$effect(() => {
if (open) {
query = ''
queueMicrotask(() => inputEl?.focus())
}
})
function choose(slug: string): void {
onSelect(slug)
open = false
}
function handleKeydown(e: KeyboardEvent): void {
if (!open) return
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex = Math.min(activeIndex + 1, results.length - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex = Math.max(activeIndex - 1, 0)
} else if (e.key === 'Enter') {
e.preventDefault()
const hit = results[activeIndex]
if (hit) choose(hit.slug)
}
}
// A window-level listener rather than one on Dialog.Content: bits-ui's
// Dialog renders its content through a portal with its own focus-trap
// wiring, and an onkeydown prop passed straight through to Content did not
// reliably receive ArrowDown/Enter in testing (focus landing inside the
// trap didn't guarantee the event reached the element this component
// attached the listener to). Capturing at the window and gating on `open`
// sidesteps that entirely — Escape-to-close is still bits-ui's own
// behavior, this only adds the list-navigation keys.
window.addEventListener('keydown', handleKeydown)
onDestroy(() => window.removeEventListener('keydown', handleKeydown))
</script>
<Dialog.Root bind:open>
<Dialog.Content
class="top-[20%] max-w-lg -translate-y-0 gap-0 p-0 sm:max-w-lg"
showCloseButton={false}
>
<Input
bind:ref={inputEl}
bind:value={query}
placeholder="Jump to a note…"
class="h-11 rounded-b-none border-0 border-b px-3 text-sm focus-visible:ring-0"
/>
<div class="max-h-80 overflow-y-auto p-1">
{#each results as it, i (it.slug)}
<button
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm {i ===
activeIndex
? 'bg-primary/10 text-primary'
: 'hover:bg-muted/50'}"
onclick={() => choose(it.slug)}
onmouseenter={() => (activeIndex = i)}
>
<span class="min-w-0 flex-1 truncate">{it.title}</span>
<StatusBadge kind="type" value={it.kind} class="shrink-0 text-[9px]" />
</button>
{:else}
<p class="py-6 text-center text-xs text-muted-foreground">No notes match "{query}".</p>
{/each}
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,485 @@
<script lang="ts">
// Center pane: read a note, edit it in place, or browse its history.
//
// `item` carries the list-derived metadata (kind, tags, about, edited_by —
// everything WikiTree already has); the full body is fetched here lazily
// per selection, same split as the API (serveKnowledgeList never returns
// content — see knowledge_write.go — so the tree stays cheap and only the
// note actually being read pays for its body).
import {
fetchKnowledgeContent,
fetchKnowledgeRevisions,
updateKnowledge,
deleteKnowledge,
KnowledgeApiError,
type KnowledgeListItem,
type KnowledgeContent,
type KnowledgeRevision
} from '$lib/api'
import { renderWikiMarkdown, slugFromKbHref, diffLines } from './wikiText'
import { kindMeta, isAgentAuthored } from './kinds'
import WikiOverview from './WikiOverview.svelte'
import { openEntityWindow } from '$lib/stores/windows'
import { relativeTime } from '$lib/utils'
import { toast } from 'svelte-sonner'
import * as Tabs from '$lib/components/ui/tabs'
import * as Dialog from '$lib/components/ui/dialog'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import { Textarea } from '$lib/components/ui/textarea'
import { Skeleton } from '$lib/components/ui/skeleton'
import PencilIcon from '@lucide/svelte/icons/pencil'
import TrashIcon from '@lucide/svelte/icons/trash-2'
import HistoryIcon from '@lucide/svelte/icons/history'
import BotIcon from '@lucide/svelte/icons/bot'
import XIcon from '@lucide/svelte/icons/x'
import SaveIcon from '@lucide/svelte/icons/save'
let {
item,
allItems,
knownSlugs,
onNavigate,
onNew,
onChanged,
dirty = $bindable(false)
}: {
item: KnowledgeListItem | null
// The whole collection — only used for the resting-state overview shown
// when nothing is selected (WikiOverview derives its figures from it).
allItems: KnowledgeListItem[]
knownSlugs: Set<string>
onNavigate: (slug: string) => void
onNew: () => void
// Fired after a save or delete that the parent's cached list needs to
// reflect (title/tags changed, or the note is gone). Parent decides
// whether to refetch the whole list or patch locally.
onChanged: () => void
// True while there's an in-progress edit that would be silently
// discarded if `item` changed out from under this component. Knowledge.svelte
// reads this before switching the selection (tree click, quick-open,
// etc.) so it can confirm with the operator first — see its
// requestSelect. Deliberately "in edit mode" rather than a real dirty
// diff against the loaded content: simpler, and erring toward "ask
// even if nothing actually changed" is the safe direction for a
// destructive-by-default operation.
dirty?: boolean
} = $props()
let content = $state<KnowledgeContent | null>(null)
let loading = $state(false)
let mode = $state<'read' | 'edit'>('read')
let tab = $state<'note' | 'history'>('note')
let saveError = $state('')
let saving = $state(false)
let draftTitle = $state('')
let draftContent = $state('')
let draftTags = $state('')
let draftAbout = $state('')
let revisions = $state<KnowledgeRevision[] | null>(null)
let revisionsLoading = $state(false)
let selectedRevisionId = $state<number | null>(null)
async function load(slug: string): Promise<void> {
loading = true
mode = 'read'
dirty = false
tab = 'note'
revisions = null
selectedRevisionId = null
saveError = ''
content = await fetchKnowledgeContent(slug)
loading = false
}
$effect(() => {
if (item) load(item.slug)
else content = null
})
function startEdit(): void {
if (!content || !item) return
draftTitle = content.title
draftContent = content.content
draftTags = content.tags.join(', ')
draftAbout = item.about.join(', ')
saveError = ''
mode = 'edit'
dirty = true
}
function cancelEdit(): void {
mode = 'read'
dirty = false
saveError = ''
}
async function save(): Promise<void> {
if (!item) return
const title = draftTitle.trim()
const body = draftContent.trim()
if (!title || !body) {
saveError = 'Title and content cannot be empty.'
return
}
saving = true
saveError = ''
const about = draftAbout
.split(',')
.map((s) => s.trim())
.filter(Boolean)
try {
const result = await updateKnowledge(item.slug, {
title,
content: body,
tags: draftTags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
about
})
// A typo'd entity slug in "About" fails to link server-side with only
// a log line (see linkKnowledgeAbout) — diff what came back against
// what was submitted so that doesn't happen silently.
const unresolved = about.filter((s) => !result.linked?.includes(s))
if (unresolved.length > 0) {
toast.error(`Couldn't link to: ${unresolved.join(', ')} — check the slug is correct.`)
}
mode = 'read'
dirty = false
await load(item.slug)
onChanged()
} catch (e) {
saveError =
e instanceof KnowledgeApiError
? `${e.message}${e.detail ? ` — ${e.detail}` : ''}`
: 'Save failed.'
} finally {
saving = false
}
}
let confirmDeleteOpen = $state(false)
let deleting = $state(false)
// Soft delete (see migrations/022_knowledge_revisions.up.sql) — the note
// goes to trash and can be brought back, so this is a lightweight confirm
// rather than anything heavier. It's an in-app Dialog rather than the
// browser's native confirm(): this app runs inside a custom floating
// window (its own desktop-shell chrome), and a native confirm() blocks
// the entire page's JS event loop until dismissed — in testing that froze
// the tab hard enough that automated clicks stopped registering
// entirely. A real dialog stays inside Svelte's event handling and can't
// wedge the app that way.
async function confirmDelete(): Promise<void> {
if (!item) return
deleting = true
try {
await deleteKnowledge(item.slug)
confirmDeleteOpen = false
onChanged()
} catch (e) {
saveError = e instanceof KnowledgeApiError ? e.message : 'Delete failed.'
} finally {
deleting = false
}
}
async function openHistory(): Promise<void> {
tab = 'history'
if (revisions !== null || !item) return
revisionsLoading = true
revisions = await fetchKnowledgeRevisions(item.slug)
selectedRevisionId = revisions[0]?.id ?? null
revisionsLoading = false
}
// Bare slugs are auto-linked (see wikiText.ts) as `#kb:<slug>` anchors.
// Intercepted here via event delegation on the rendered container — the
// markdown body is injected with {@html}, so component-level click
// bindings can't attach to individual links, but a plain bubbling
// listener on the wrapper works the same as it would for real DOM.
function handleContentClick(e: MouseEvent): void {
const anchor = (e.target as HTMLElement).closest('a')
if (!anchor) return
const slug = slugFromKbHref(anchor.getAttribute('href'))
if (!slug) return
e.preventDefault()
if (knownSlugs.has(slug)) onNavigate(slug)
else openEntityWindow(slug)
}
const selectedRevision = $derived(revisions?.find((r) => r.id === selectedRevisionId) ?? null)
// Diff against the CURRENT live body, not the next revision — the History
// tab answers "what did this look like before it became what it is now,"
// not "what changed between two arbitrary edits."
const diff = $derived(
selectedRevision && content ? diffLines(selectedRevision.content, content.content) : null
)
</script>
<div class="flex h-full min-w-0 flex-col gap-2">
{#if !item}
<WikiOverview items={allItems} onSelect={onNavigate} {onNew} />
{:else if loading}
<!-- Shaped like the loaded header/tags/body below rather than a
centered spinner, so the switch from "loading" to "loaded" is a
content swap, not a layout jump — the title, meta line, tag row,
and first few lines of body all keep their real position. -->
<div class="flex items-start justify-between gap-2 border-b pb-2.5">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<Skeleton class="size-4 shrink-0 rounded" />
<Skeleton class="h-5 w-56" />
</div>
<div class="mt-2 flex items-center gap-2">
<Skeleton class="h-3 w-16" />
<Skeleton class="h-3 w-20" />
<Skeleton class="h-3 w-24" />
</div>
</div>
<Skeleton class="h-7 w-16 shrink-0" />
</div>
<div class="flex flex-wrap gap-1.5 pt-3">
<Skeleton class="h-5 w-14 rounded-full" />
<Skeleton class="h-5 w-16 rounded-full" />
</div>
<div class="flex flex-col gap-2.5 pt-2">
{#each Array(6) as _, i (i)}
<Skeleton class="h-4" style="width: {i === 5 ? 45 : 96 - i * 4}%" />
{/each}
</div>
{:else if !content}
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
Couldn't load this note.
</div>
{:else}
<!-- Header: kind + title, then a single provenance line. Previously these
were one wrapping row of badges and text fragments; splitting
"what this is" from "where it came from" stops the title competing
with its own metadata. -->
<div class="flex items-start justify-between gap-2 border-b pb-2.5">
<div class="min-w-0 flex-1">
{#if mode === 'edit'}
<Input bind:value={draftTitle} class="mb-1 h-8 font-medium" placeholder="Title" />
{:else}
{@const KindIcon = kindMeta(item.kind).icon}
<div class="flex min-w-0 items-center gap-2">
<KindIcon class="size-4 shrink-0 text-muted-foreground" />
<h2 class="truncate text-base font-semibold">{content.title}</h2>
</div>
{/if}
<div
class="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground"
>
<span class="capitalize">{kindMeta(item.kind).label}</span>
<span aria-hidden="true">·</span>
{#if isAgentAuthored(content.edited_by)}
<span class="flex items-center gap-1 text-primary">
<BotIcon class="size-3" /> Nomos
</span>
{:else if content.edited_by}
<span>{content.edited_by}</span>
{:else}
<span>unknown author</span>
{/if}
<span aria-hidden="true">·</span>
<span>updated {relativeTime(content.updated_at)}</span>
{#if content.revisions > 0}
<span aria-hidden="true">·</span>
<button
type="button"
class="underline decoration-dotted underline-offset-2 hover:text-foreground"
onclick={openHistory}
>
{content.revisions} revision{content.revisions === 1 ? '' : 's'}
</button>
{/if}
</div>
</div>
<div class="flex shrink-0 gap-1">
{#if mode === 'read'}
<Button size="sm" variant="outline" class="h-7 gap-1 text-xs" onclick={startEdit}>
<PencilIcon class="size-3.5" /> Edit
</Button>
<Button
size="sm"
variant="ghost"
class="h-7 gap-1 text-xs text-destructive"
onclick={() => (confirmDeleteOpen = true)}
>
<TrashIcon class="size-3.5" />
</Button>
{:else}
<Button
size="sm"
variant="ghost"
class="h-7 gap-1 text-xs"
onclick={cancelEdit}
disabled={saving}
>
<XIcon class="size-3.5" /> Cancel
</Button>
<Button size="sm" class="h-7 gap-1 text-xs" onclick={save} disabled={saving}>
<SaveIcon class="size-3.5" />
{saving ? 'Saving…' : 'Save'}
</Button>
{/if}
</div>
</div>
{#if saveError}
<p
class="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
>
{saveError}
</p>
{/if}
{#if mode === 'edit'}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<Textarea
bind:value={draftContent}
class="min-h-[240px] flex-1 resize-none font-mono text-xs"
placeholder="Markdown content…"
/>
<label class="text-xs text-muted-foreground" for="wiki-tags">
Tags (comma-separated)
<Input
id="wiki-tags"
bind:value={draftTags}
class="mt-1 h-7 text-xs"
placeholder="oom, rclone, gotcha"
/>
</label>
<label class="text-xs text-muted-foreground" for="wiki-about">
About (entity slugs, comma-separated)
<Input
id="wiki-about"
bind:value={draftAbout}
class="mt-1 h-7 text-xs"
placeholder="host:strong, lxc:gitea"
/>
</label>
</div>
{:else}
<Tabs.Root bind:value={tab} class="flex min-h-0 flex-1 flex-col">
<Tabs.List class="h-7 w-fit">
<Tabs.Trigger value="note" class="text-xs">Note</Tabs.Trigger>
<Tabs.Trigger value="history" class="gap-1 text-xs" onclick={openHistory}>
<HistoryIcon class="size-3" /> History
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="note" class="min-h-0 flex-1 overflow-y-auto pt-3">
{#if item.tags.length}
<div class="mb-3 flex max-w-[68ch] flex-wrap gap-1.5">
{#each item.tags as t (t)}<span
class="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground"
>{t}</span
>{/each}
</div>
{/if}
<!-- event delegation over rendered markdown: the interactive elements are the <a>
tags inside, already keyboard-operable on their own. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- max-w-[68ch]: without a measure the body ran the full width of a
resizable pane, which at a wide split is well past the ~75ch
where prose stops being comfortable to read. -->
<div
class="markdown-body max-w-[68ch] text-sm leading-relaxed"
onclick={handleContentClick}
>
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify in renderWikiMarkdown -->
{@html renderWikiMarkdown(content.content)}
</div>
</Tabs.Content>
<Tabs.Content value="history" class="min-h-0 flex-1 overflow-y-auto pt-2">
{#if revisionsLoading}
<div class="flex gap-3">
<div class="flex w-40 shrink-0 flex-col gap-2 px-2 py-1">
{#each Array(4) as _, i (i)}
<div class="flex flex-col gap-1">
<Skeleton class="h-3 w-16" />
<Skeleton class="h-2.5 w-20" />
</div>
{/each}
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1.5 rounded border p-2">
{#each Array(8) as _, i (i)}
<Skeleton class="h-3" style="width: {90 - (i % 4) * 15}%" />
{/each}
</div>
</div>
{:else if !revisions || revisions.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">
No prior revisions — this is the first version.
</p>
{:else}
<div class="flex gap-3">
<div class="flex w-40 shrink-0 flex-col gap-0.5">
{#each revisions as rev (rev.id)}
<button
type="button"
class="rounded px-2 py-1 text-left text-[11px] hover:bg-muted/50 {selectedRevisionId ===
rev.id
? 'bg-primary/10 text-primary'
: ''}"
onclick={() => (selectedRevisionId = rev.id)}
>
<div class="font-medium">{relativeTime(rev.version_at)}</div>
<div class="text-muted-foreground">{rev.edited_by || 'unknown'}</div>
</button>
{/each}
</div>
<div class="min-w-0 flex-1 overflow-x-auto rounded border">
{#if diff}
<pre class="p-2 text-[11px] leading-relaxed">{#each diff as op, i (i)}<div
class={op.type === 'add'
? 'bg-success/10 text-success'
: op.type === 'remove'
? 'bg-destructive/10 text-destructive line-through'
: ''}>{op.type === 'add'
? '+ '
: op.type === 'remove'
? '- '
: ' '}{op.line}</div>{/each}</pre>
{/if}
</div>
</div>
{/if}
</Tabs.Content>
</Tabs.Root>
{/if}
{/if}
</div>
<Dialog.Root bind:open={confirmDeleteOpen}>
<Dialog.Content class="sm:max-w-sm">
<Dialog.Header>
<Dialog.Title>Delete note?</Dialog.Title>
<Dialog.Description>
{#if item}"{item.title}" will move to Trash and can be restored from there.{/if}
</Dialog.Description>
</Dialog.Header>
{#if saveError}
<p
class="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
>
{saveError}
</p>
{/if}
<Dialog.Footer>
<Button variant="ghost" onclick={() => (confirmDeleteOpen = false)} disabled={deleting}
>Cancel</Button
>
<Button variant="destructive" onclick={confirmDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,198 @@
<script lang="ts">
// Left pane of the Knowledge wiki: a tree over every live note, with a
// grouping switch so the same 102 notes are reachable four different
// ways — which one helps depends on what the operator already remembers
// about the thing they're looking for (its topic, its type, a tag, or the
// machine it concerns).
import type { KnowledgeListItem } from '$lib/api'
import { groupNotes, type GroupBy } from './wikiText'
import { Input } from '$lib/components/ui/input'
import * as Select from '$lib/components/ui/select'
import * as Collapsible from '$lib/components/ui/collapsible'
import { Button } from '$lib/components/ui/button'
import { kindMeta, isAgentAuthored } from './kinds'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import SearchIcon from '@lucide/svelte/icons/search'
import PlusIcon from '@lucide/svelte/icons/plus'
import BotIcon from '@lucide/svelte/icons/bot'
import FolderTreeIcon from '@lucide/svelte/icons/folder-tree'
let {
items,
selectedSlug,
onSelect,
onNew
}: {
items: KnowledgeListItem[]
selectedSlug: string | null
onSelect: (slug: string) => void
onNew: () => void
} = $props()
const GROUP_LABELS: Record<GroupBy, string> = {
folder: 'Folder',
kind: 'Type',
tag: 'Tag',
entity: 'Entity'
}
function loadGroupBy(): GroupBy {
if (typeof localStorage === 'undefined') return 'folder'
const v = localStorage.getItem('oikos-wiki-groupby')
return v === 'kind' || v === 'tag' || v === 'entity' ? v : 'folder'
}
let groupBy = $state<GroupBy>(loadGroupBy())
let filter = $state('')
function setGroupBy(v: string): void {
if (v !== 'folder' && v !== 'kind' && v !== 'tag' && v !== 'entity') return
groupBy = v
if (typeof localStorage !== 'undefined') localStorage.setItem('oikos-wiki-groupby', v)
}
const filtered = $derived.by(() => {
const q = filter.trim().toLowerCase()
if (!q) return items
return items.filter(
(it) =>
it.title.toLowerCase().includes(q) ||
it.slug.toLowerCase().includes(q) ||
it.tags.some((t) => t.toLowerCase().includes(q))
)
})
const groups = $derived(groupNotes(filtered, groupBy))
// Every group starts open when the filter is active (so a match is never
// hidden inside a collapsed group) and only the group containing the
// current selection starts open otherwise — with 102 notes across ~15
// folders, all-open-by-default would just be a long undifferentiated
// scroll.
let openGroups = $state<Set<string>>(new Set())
$effect(() => {
if (filter.trim()) {
openGroups = new Set(groups.map((g) => g.key))
return
}
const owning = groups.find((g) => g.items.some((it) => it.slug === selectedSlug))
openGroups = new Set(owning ? [owning.key] : groups[0] ? [groups[0].key] : [])
})
function toggleGroup(key: string): void {
const next = new Set(openGroups)
if (next.has(key)) next.delete(key)
else next.add(key)
openGroups = next
}
</script>
<div class="flex h-full flex-col gap-1.5">
<!-- Search and "new note" share a row: both act on the list as a whole,
and pairing them lets the field take the remaining width instead of
being squeezed by a fixed-width control beside it. -->
<div class="flex items-center gap-1.5">
<div class="relative flex-1">
<SearchIcon class="absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="Filter notes…" bind:value={filter} class="h-7 pl-7 text-xs" />
</div>
<Button
size="sm"
variant="outline"
class="size-7 shrink-0 p-0"
onclick={onNew}
title="New note"
aria-label="New note"
>
<PlusIcon class="size-3.5" />
</Button>
</div>
<!-- Group-by reads as a caption for the tree rather than a third boxed
input: it labels how the list below is arranged, so it's styled like
the group headers it governs (same muted 11px) and only reveals itself
as a control on hover. Its own row of chrome was competing with the
search field for attention while doing far less work. -->
<Select.Root type="single" value={groupBy} onValueChange={setGroupBy}>
<Select.Trigger
size="sm"
class="h-auto w-fit gap-1 rounded border-0 bg-transparent px-1 py-0.5 text-[11px] font-normal tracking-wide text-muted-foreground uppercase shadow-none hover:bg-muted/40 hover:text-foreground focus-visible:ring-0 data-[size=sm]:h-auto dark:bg-transparent dark:hover:bg-muted/40"
title="Change how notes are grouped"
>
<FolderTreeIcon class="size-3 opacity-70" />
by {GROUP_LABELS[groupBy]}
</Select.Trigger>
<Select.Content>
{#each Object.entries(GROUP_LABELS) as [key, label] (key)}
<Select.Item value={key} {label}>{label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<div class="-mx-1 min-h-0 flex-1 overflow-y-auto px-1">
{#each groups as group (group.key)}
{@const isOpen = openGroups.has(group.key)}
<Collapsible.Root open={isOpen} onOpenChange={() => toggleGroup(group.key)}>
<Collapsible.Trigger
class="group/grp flex w-full cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left select-none hover:bg-muted/40"
>
<ChevronRightIcon
class="size-3 shrink-0 text-muted-foreground transition-transform duration-150 {isOpen
? 'rotate-90'
: ''}"
/>
<span
class="min-w-0 flex-1 truncate text-[11px] font-medium tracking-wide text-muted-foreground uppercase group-hover/grp:text-foreground"
>{group.label}</span
>
<span class="shrink-0 text-[10px] tabular-nums text-muted-foreground/70"
>{group.items.length}</span
>
</Collapsible.Trigger>
<Collapsible.Content>
<!-- The guide rule sits inside the indent rather than on each row so
it reads as one continuous line down the group. -->
<div class="mb-1 ml-[13px] flex flex-col border-l border-border/60 pl-1.5">
{#each group.items as it (it.slug + group.key)}
{@const selected = selectedSlug === it.slug}
{@const Icon = kindMeta(it.kind).icon}
<button
type="button"
title={it.title}
class="relative flex items-center gap-2 rounded-md py-1.5 pr-1.5 pl-2 text-left text-xs transition-colors {selected
? 'bg-primary/10 font-medium text-primary'
: 'hover:bg-muted/50'}"
onclick={() => onSelect(it.slug)}
>
<!-- Selection also gets an accent bar on the guide rule: the
background tint alone is easy to lose against the window's
own surface at this size. -->
{#if selected}
<span
class="absolute top-1 bottom-1 -left-[7px] w-[2px] rounded-full bg-primary"
aria-hidden="true"
></span>
{/if}
<Icon
class="size-3.5 shrink-0 {selected ? 'text-primary' : 'text-muted-foreground/70'}"
/>
<span class="min-w-0 flex-1 truncate">{it.title}</span>
{#if isAgentAuthored(it.edited_by)}
<BotIcon
class="size-3 shrink-0 {selected
? 'text-primary/70'
: 'text-muted-foreground/50'}"
/>
{/if}
</button>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{:else}
<p class="px-2 py-8 text-center text-xs text-muted-foreground">
No notes match &ldquo;{filter}&rdquo;.
</p>
{/each}
</div>
</div>

View File

@@ -0,0 +1,50 @@
// Per-kind presentation, shared by the tree, the overview, and anywhere else
// a note's kind needs to be shown at a glance.
//
// Kind is encoded by **icon shape**, not colour. Three categories would be a
// categorical palette, and at the 12px mark size the tree uses, colour alone
// is the least reliable channel there is — it fails for colour-vision
// deficiency, and small low-chroma marks on a dark surface are hard for
// anyone to tell apart. Distinct silhouettes are legible at any size, in any
// theme, for every reader. Colour is left to carry state (selection, the
// agent badge), where it isn't the only thing distinguishing two items.
//
// It also fixes a plain redundancy: the tree previously stamped a literal
// "document" text badge on every row, which in a folder of 20 documents is
// 20 repetitions of the same word and no information at all.
import FileTextIcon from '@lucide/svelte/icons/file-text'
import MicroscopeIcon from '@lucide/svelte/icons/microscope'
import ListChecksIcon from '@lucide/svelte/icons/list-checks'
import type { Component } from 'svelte'
export type NoteKind = 'document' | 'investigation' | 'runbook'
export interface KindMeta {
icon: Component
label: string
/** Plural, for counts and section headings. */
plural: string
}
const FALLBACK: KindMeta = { icon: FileTextIcon, label: 'note', plural: 'notes' }
const KIND_META: Record<NoteKind, KindMeta> = {
document: { icon: FileTextIcon, label: 'document', plural: 'documents' },
investigation: { icon: MicroscopeIcon, label: 'investigation', plural: 'investigations' },
runbook: { icon: ListChecksIcon, label: 'runbook', plural: 'runbooks' }
}
// Tolerates an unknown kind rather than throwing — `kind` comes from the
// entity's type column, which the ontology could grow a fourth value for
// without this file knowing.
export function kindMeta(kind: string): KindMeta {
return KIND_META[kind as NoteKind] ?? FALLBACK
}
// True for notes last written by the agent rather than a human. Two spellings
// exist in the live data: 'nomos-agent' (written via the MCP upsert_knowledge
// tool) and 'agent:mcp' (the actor label the HTTP API records when the same
// agent calls in over REST with the MCP bearer token).
export function isAgentAuthored(editedBy: string): boolean {
return editedBy === 'nomos-agent' || editedBy === 'agent:mcp'
}

View File

@@ -0,0 +1,197 @@
// Shared text helpers for the knowledge wiki (Knowledge.svelte and its
// components). Markdown rendering, slug auto-linking, folder/grouping
// derivation, and a small line diff for the History view — split out from
// any one component since WikiReader and WikiContextRail both need the
// rendering/linking half, and WikiTree needs the grouping half.
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import type { KnowledgeListItem } from '$lib/api'
// Matches a bare entity/knowledge slug like `lxc:gitea` or
// `document:containers/101-jellyfin` — real examples pulled straight from
// the data (`grep`-confirmed: operators and Nomos both write bare slugs
// throughout note bodies today). The `[[wiki-link]]` bracket syntax some
// wikis use was considered and dropped: only one note in the live DB
// contains "[[" at all, and it's an HTML comment, not a link — building
// bracket-syntax parsing would add real complexity (nesting, alias syntax,
// double-substitution risk with this very regex) for a feature nobody
// writes.
//
// Anchored to start with a lowercase letter specifically to reject
// clock-times like "10:08" or "20:40" that are common in this dataset's
// investigation titles/bodies (digits don't match `[a-z]`) and to reject
// "https://..." (the char after ':' there is '/', which fails the
// alnum-first requirement on the right-hand side).
const SLUG_PATTERN = `\\b([a-z][a-z0-9-]{1,30}:[a-zA-Z0-9][a-zA-Z0-9\\-/._]*)\\b`
// A fenced code block (```...```, across lines) or an inline code span
// (`...`, single line) OR a bare slug — tried in that order at every
// position. Fenced/inline code always wins the match when present, so a
// slug-shaped token *inside* a code example (a runbook's shell snippet
// referencing e.g. `host:strong/some-path`) is consumed whole as code and
// never reaches the slug branch. Without this, linkifySlugs ran the slug
// regex over raw markdown with no idea code existed, rewrote the slug
// inside the span to `[slug](#kb:slug)`, and `marked` then rendered that
// literal bracket/paren syntax as text inside the <code> tag instead of
// treating it as code. Doesn't handle every markdown code-span edge case
// (double-backtick escaping for spans containing a literal backtick, `~~~`
// fences) — just the two forms actually used in this corpus.
const TOKEN_PATTERN = new RegExp('(```[\\s\\S]*?```)|(`[^`\\n]+`)|(' + SLUG_PATTERN + ')', 'g')
// Wraps every bare slug in `text` with a placeholder markdown link
// (`[slug](#kb:slug)`) before it reaches `marked`, so the renderer emits a
// real `<a>` that the reader's click handler (see WikiReader.svelte) can
// intercept. The `#kb:` prefix is never a real anchor on this page — it's
// just a tag so the click handler can tell "one of ours" apart from a
// legitimate external link without inspecting every href.
//
// Trailing punctuation immediately after a slug (a period ending a
// sentence, a comma, a closing paren) is peeled off and left outside the
// link — "see host:strong." must not swallow the sentence's full stop into
// the link target.
function linkifySlugs(text: string): string {
return text.replace(TOKEN_PATTERN, (match, fence, inlineCode) => {
if (fence || inlineCode) return match // code — leave untouched, see TOKEN_PATTERN's comment
const trailing = match.match(/[.,;:)]+$/)?.[0] ?? ''
const slug = trailing ? match.slice(0, -trailing.length) : match
if (!slug.includes(':')) return match // shouldn't happen given the pattern, but stay safe
return `[${slug}](#kb:${encodeURIComponent(slug)})${trailing}`
})
}
// Full markdown render for the reader pane: linkify first (plain text, so
// the regex never sees HTML), then render, then sanitize. Mirrors
// EntityDetailContent.svelte's renderMarkdown (marked + DOMPurify, no tag
// restriction) rather than Knowledge.svelte's old snippet-only sanitize
// (which allowlisted only `<b>` for ts_headline output) — this renders a
// full note body, not a search snippet.
export function renderWikiMarkdown(text: string): string {
const linked = linkifySlugs(text)
return DOMPurify.sanitize(marked.parse(linked, { async: false }) as string)
}
// Parses a `#kb:<encoded-slug>` href back into the slug, or null if `href`
// isn't one of ours (a real external/relative link the browser should
// handle normally).
export function slugFromKbHref(href: string | null): string | null {
if (!href || !href.startsWith('#kb:')) return null
try {
return decodeURIComponent(href.slice('#kb:'.length))
} catch {
return null
}
}
// ─── Grouping (navigator tree) ─────────────────────────────────────────────
export type GroupBy = 'folder' | 'kind' | 'tag' | 'entity'
const UNGROUPED = '(ungrouped)'
// The slug format is `<kind>:<folder>/<name>` for namespaced notes (agent
// and seeded content) or plain `<kind>:<name>` for the flat runbooks
// (runbook:lifecycle-activate-node). The latter has no folder segment, so
// it groups under UNGROUPED rather than being silently dropped.
export function noteFolder(item: KnowledgeListItem): string {
const afterColon = item.slug.slice(item.slug.indexOf(':') + 1)
const idx = afterColon.lastIndexOf('/')
return idx === -1 ? UNGROUPED : afterColon.slice(0, idx)
}
export interface WikiGroup {
key: string
label: string
items: KnowledgeListItem[]
}
// Groups `items` by the chosen dimension. `tag` and `entity` are
// many-to-many — a note with three tags appears in three groups — which is
// deliberate: those two modes are for "show me everything touching X," not
// a strict partition like folder/kind are.
export function groupNotes(items: KnowledgeListItem[], by: GroupBy): WikiGroup[] {
const groups = new Map<string, KnowledgeListItem[]>()
const push = (key: string, item: KnowledgeListItem) => {
const arr = groups.get(key)
if (arr) arr.push(item)
else groups.set(key, [item])
}
for (const item of items) {
switch (by) {
case 'folder':
push(noteFolder(item), item)
break
case 'kind':
push(item.kind, item)
break
case 'tag':
if (item.tags.length === 0) push(UNGROUPED, item)
else for (const t of item.tags) push(t, item)
break
case 'entity':
if (item.about.length === 0) push(UNGROUPED, item)
else for (const slug of item.about) push(slug, item)
break
}
}
const out: WikiGroup[] = [...groups.entries()].map(([key, groupItems]) => ({
key,
label: key,
items: groupItems.sort((a, b) => a.title.localeCompare(b.title))
}))
// Ungrouped/misc always last; otherwise alphabetical, largest-first ties
// broken by label so the ordering is stable across reloads.
out.sort((a, b) => {
if (a.key === UNGROUPED) return 1
if (b.key === UNGROUPED) return -1
return a.label.localeCompare(b.label)
})
return out
}
// ─── Line diff (History tab) ───────────────────────────────────────────────
export type DiffOp = { type: 'equal' | 'add' | 'remove'; line: string }
// Textbook O(n*m) LCS-based line diff. Notes in this system are small
// (the seed data averages ~1KB, agent-written investigations rarely exceed
// 2KB, so a few dozen lines at most) — the quadratic cost is invisible at
// this size and a full Myers-diff dependency would be a lot of code for a
// feature that only needs to render a readable before/after in the History
// tab, not power a merge tool.
export function diffLines(oldText: string, newText: string): DiffOp[] {
const a = oldText.split('\n')
const b = newText.split('\n')
const n = a.length
const m = b.length
// lcs[i][j] = length of the LCS of a[i:] and b[j:]
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0))
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1])
}
}
const ops: DiffOp[] = []
let i = 0
let j = 0
while (i < n && j < m) {
if (a[i] === b[j]) {
ops.push({ type: 'equal', line: a[i] })
i++
j++
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
ops.push({ type: 'remove', line: a[i] })
i++
} else {
ops.push({ type: 'add', line: b[j] })
j++
}
}
while (i < n) ops.push({ type: 'remove', line: a[i++] })
while (j < m) ops.push({ type: 'add', line: b[j++] })
return ops
}

View File

@@ -1,162 +1,267 @@
<script lang="ts">
import DOMPurify from 'dompurify'
import { searchKnowledge, fetchRecentKnowledge, type KnowledgeHit, type RecentKnowledge } 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'
// The Knowledge app, redesigned as a wiki: browse, read, and now actually
// edit everything the operator and Nomos have recorded — 102 notes that
// were previously stats-only (the old page could search and show a
// recency feed, but nothing was clickable: see git history on this file).
//
// Three panes (WikiTree | WikiReader | WikiContextRail) for browsing and
// editing day to day, plus a separate Cleanup mode (WikiCleanup) for the
// maintenance work the collection actually needs — duplicate pileups,
// tag-casing drift, orphaned notes, and the trash. Both modes share the
// same `items` list, loaded once here.
import { onMount } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { listKnowledge, KnowledgeApiError, type KnowledgeListItem } from '$lib/api'
import WikiTree from '$lib/components/knowledge/WikiTree.svelte'
import WikiReader from '$lib/components/knowledge/WikiReader.svelte'
import WikiContextRail from '$lib/components/knowledge/WikiContextRail.svelte'
import WikiCleanup from '$lib/components/knowledge/WikiCleanup.svelte'
import WikiNewDialog from '$lib/components/knowledge/WikiNewDialog.svelte'
import WikiQuickOpen from '$lib/components/knowledge/WikiQuickOpen.svelte'
import * as Dialog from '$lib/components/ui/dialog'
import { Button } from '$lib/components/ui/button'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import { openEntityWindow } from '$lib/stores/windows'
import { relativeTime } from '$lib/utils'
import StatusBadge from '$lib/components/StatusBadge.svelte'
import SearchIcon from '@lucide/svelte/icons/search'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import BotIcon from '@lucide/svelte/icons/bot'
import { Skeleton } from '$lib/components/ui/skeleton'
import WrenchIcon from '@lucide/svelte/icons/wrench'
import BookOpenIcon from '@lucide/svelte/icons/book-open'
let query = $state('')
let results = $state<KnowledgeHit[]>([])
let loading = $state(false)
let searched = $state(false)
type Mode = 'wiki' | 'cleanup'
let recent = $state<RecentKnowledge>({ stats: { total: 0, by_kind: {}, agent_authored: 0, last_7d: 0 }, items: [] })
let agentOnly = $state(false)
let loadingRecent = $state(true)
let items = $state<KnowledgeListItem[]>([])
let itemsLoading = $state(true)
let selectedSlug = $state<string | null>(null)
let mode = $state<Mode>('wiki')
let newDialogOpen = $state(false)
let quickOpenOpen = $state(false)
// True while WikiReader has an in-progress, unsaved edit — set via
// bind:dirty. requestSelect below is the single choke point every
// selection path (tree, context rail, quick-open, a newly-created note)
// goes through, so gating it here is enough to stop a stray click from
// silently discarding an edit in progress (see WikiReader's own comment
// on why this is "in edit mode" rather than a real dirty-diff).
let readerDirty = $state(false)
let pendingSlug = $state<string | null>(null)
// Distinct from "0 notes": listKnowledge now throws on a failed request
// rather than returning [] (a real outage was previously indistinguishable
// from an empty collection — see api.ts's comment on the same helper).
let loadError = $state('')
async function loadRecent() {
loadingRecent = true
recent = await fetchRecentKnowledge(agentOnly ? 'nomos-agent' : undefined)
loadingRecent = false
}
loadRecent()
function toggleAgentOnly() {
agentOnly = !agentOnly
loadRecent()
async function loadItems(): Promise<void> {
itemsLoading = true
loadError = ''
try {
items = await listKnowledge()
} catch (e) {
loadError = e instanceof KnowledgeApiError ? e.message : 'Failed to load notes.'
} finally {
itemsLoading = false
}
}
async function search() {
if (!query.trim()) { searched = false; return }
loading = true
results = await searchKnowledge(query)
loading = false
searched = true
// Cmd/Ctrl+K quick-open, scoped to while this app's window is around —
// Desktop.svelte doesn't have a global command-palette convention to hook
// into, so this is a plain window listener added/removed with the
// component's lifetime rather than a shell-level keybinding.
function handleKeydown(e: KeyboardEvent): void {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
quickOpenOpen = true
}
}
onMount(() => {
loadItems()
window.addEventListener('keydown', handleKeydown)
return () => window.removeEventListener('keydown', handleKeydown)
})
// Every note's own slug, for WikiReader's click handler to tell a
// wiki-internal link (navigate in place) from an entity link (open an
// entity window) — see wikiText.ts's slugFromKbHref.
const knownSlugs = $derived(new Set(items.map((i) => i.slug)))
const selectedItem = $derived(items.find((i) => i.slug === selectedSlug) ?? null)
function selectNow(slug: string): void {
readerDirty = false
selectedSlug = slug
mode = 'wiki'
}
// Every selection path (tree click, context-rail click, quick-open,
// opening a just-created note) routes through here rather than mutating
// selectedSlug directly, so an in-progress edit can never be silently
// discarded by a stray click elsewhere in the app.
function requestSelect(slug: string): void {
if (slug === selectedSlug) {
mode = 'wiki' // already selected — e.g. Cleanup's "view" links back into Wiki mode
return
}
if (readerDirty) {
pendingSlug = slug
return
}
selectNow(slug)
}
function confirmDiscardAndSwitch(): void {
if (pendingSlug) selectNow(pendingSlug)
pendingSlug = null
}
function handleCreated(slug: string): void {
loadItems()
requestSelect(slug)
}
</script>
<div class="flex h-full flex-col gap-4 p-2">
<div class="flex h-full flex-col gap-2 p-2">
<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>
<StatusBadge kind="type" value={hit.type} />
</div>
{#if hit.snippet}
<Card.Description class="text-xs">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized inline; ts_headline only ever emits <b> -->
{@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 class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">{items.length} notes</span>
<button
type="button"
class="rounded border px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground hover:bg-muted/50"
onclick={() => (quickOpenOpen = true)}
>
⌘K
</button>
<div class="inline-flex overflow-hidden rounded-md border">
<button
type="button"
class="flex items-center gap-1 px-2 py-1 text-xs {mode === 'wiki'
? 'bg-secondary text-secondary-foreground'
: 'hover:bg-muted/50'}"
onclick={() => (mode = 'wiki')}
>
<BookOpenIcon class="size-3.5" /> Wiki
</button>
<button
type="button"
class="flex items-center gap-1 px-2 py-1 text-xs {mode === 'cleanup'
? 'bg-secondary text-secondary-foreground'
: 'hover:bg-muted/50'}"
onclick={() => (mode = 'cleanup')}
>
<WrenchIcon class="size-3.5" /> Cleanup
</button>
</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>
<StatusBadge kind="type" value={it.kind} class="text-[10px]" />
{#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>
<div class="min-h-0 flex-1">
{#if itemsLoading}
<!-- Shaped like the real three-pane layout below (same Splitpanes
proportions) rather than a centered spinner: a spinner tells the
operator nothing about what's about to render and costs a full
reflow the instant real content lands. This costs none — the
panes are already the right size. -->
<Splitpanes theme="oikos-theme" dblClickSplitter={false} class="h-full">
<Pane size={22} minSize={15} maxSize={40}>
<div class="flex h-full flex-col gap-3 overflow-hidden p-1.5">
<Skeleton class="h-7 w-full" />
<Skeleton class="h-3 w-16" />
<div class="flex flex-col gap-3">
{#each Array(4) as _, gi (gi)}
<div class="flex flex-col gap-1.5">
<Skeleton class="h-3 w-20" />
<div class="ml-3 flex flex-col gap-2 border-l pl-2">
{#each Array(3) as _, ri (ri)}
<Skeleton class="h-3.5" style="width: {75 - ri * 15}%" />
{/each}
</div>
</div>
{/if}
{/each}
</div>
<span class="shrink-0 text-xs text-muted-foreground">{relativeTime(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}
</Pane>
<Pane size={56} minSize={30}>
<div class="flex h-full flex-col gap-3 overflow-hidden p-2">
<Skeleton class="h-4 w-32" />
<Skeleton class="h-9 w-40" />
<div class="mt-1 grid grid-cols-4 gap-px overflow-hidden rounded-lg bg-border/60">
{#each Array(4) as _, i (i)}
<div class="flex flex-col gap-1.5 bg-card px-3 py-2.5">
<Skeleton class="h-3 w-14" />
<Skeleton class="h-5 w-8" />
</div>
{/each}
</div>
<Skeleton class="mt-2 h-3 w-full" />
<div class="mt-2 flex flex-col gap-2.5">
{#each Array(5) as _, i (i)}
<Skeleton class="h-4" style="width: {85 - i * 8}%" />
{/each}
</div>
</div>
</Pane>
</Splitpanes>
{:else if loadError}
<div class="flex h-full flex-col items-center justify-center gap-2 text-sm">
<p class="text-destructive">{loadError}</p>
<Button size="sm" variant="outline" onclick={loadItems}>Retry</Button>
</div>
</ScrollArea>
{/if}
{:else if mode === 'wiki'}
<Splitpanes theme="oikos-theme" dblClickSplitter={false} class="h-full">
<Pane size={22} minSize={15} maxSize={40}>
<div class="h-full overflow-hidden p-1.5">
<WikiTree
{items}
{selectedSlug}
onSelect={requestSelect}
onNew={() => (newDialogOpen = true)}
/>
</div>
</Pane>
<Pane size={56} minSize={30}>
<div class="h-full overflow-hidden p-2">
<WikiReader
item={selectedItem}
allItems={items}
{knownSlugs}
onNavigate={requestSelect}
onNew={() => (newDialogOpen = true)}
onChanged={loadItems}
bind:dirty={readerDirty}
/>
</div>
</Pane>
<!-- The context rail is about the selected note, so it only exists
when there is one. Left mounted it rendered a "Nothing selected."
placeholder next to the overview — two competing empty states,
and a fifth of the width spent saying nothing. -->
{#if selectedItem}
<Pane size={22} minSize={15} maxSize={40}>
<div class="h-full overflow-hidden p-1.5">
<WikiContextRail item={selectedItem} allItems={items} onSelect={requestSelect} />
</div>
</Pane>
{/if}
</Splitpanes>
{:else}
<WikiCleanup onSelect={requestSelect} onChanged={loadItems} />
{/if}
</div>
</div>
<WikiNewDialog bind:open={newDialogOpen} onCreated={handleCreated} />
<WikiQuickOpen bind:open={quickOpenOpen} {items} onSelect={requestSelect} />
<Dialog.Root
open={pendingSlug !== null}
onOpenChange={(o) => {
if (!o) pendingSlug = null
}}
>
<Dialog.Content class="sm:max-w-sm">
<Dialog.Header>
<Dialog.Title>Discard unsaved changes?</Dialog.Title>
<Dialog.Description>
You're editing a note. Switching now will discard what you haven't saved.
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (pendingSlug = null)}>Keep editing</Button>
<Button variant="destructive" onclick={confirmDiscardAndSwitch}>Discard and switch</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>