Compare commits
10 Commits
482c7f3448
...
claude/web
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e1ccad5f4 | |||
| 89312a9ce4 | |||
| ce0e4142ff | |||
| 873b00ac42 | |||
| b345783eef | |||
| 29d5cb8b85 | |||
| 8f440c5ad5 | |||
| 4e4e2c169c | |||
| c151a66627 | |||
| 1c12d40712 |
@@ -50,6 +50,7 @@ func (s *Server) serveRecentKnowledge(w http.ResponseWriter, req *http.Request)
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ($1 = '' OR ke.source = $1)
|
||||
AND ke.deleted_at IS NULL
|
||||
ORDER BY ke.updated_at DESC
|
||||
LIMIT $2`, source, limit)
|
||||
if err != nil {
|
||||
@@ -82,6 +83,7 @@ func (s *Server) serveRecentKnowledge(w http.ResponseWriter, req *http.Request)
|
||||
COUNT(*) FILTER (WHERE ke.source = 'nomos-agent'),
|
||||
COUNT(*) FILTER (WHERE ke.updated_at > now() - interval '7 days')
|
||||
FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ke.deleted_at IS NULL
|
||||
GROUP BY e.type`)
|
||||
if err == nil {
|
||||
defer srows.Close()
|
||||
@@ -128,15 +130,19 @@ func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
var title, content, source string
|
||||
var title, content, source, editedBy string
|
||||
var tags []string
|
||||
var updatedAt string
|
||||
var revisions int
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
SELECT ke.title, ke.content, COALESCE(ke.source,''), ke.tags, ke.updated_at::text
|
||||
SELECT ke.title, ke.content, COALESCE(ke.source,''), COALESCE(ke.edited_by,''),
|
||||
ke.tags, ke.updated_at::text,
|
||||
(SELECT count(*) FROM knowledge_revisions kr WHERE kr.entity_id = ke.entity_id)
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE e.slug = $1 OR e.id::text = $1`, idOrSlug).
|
||||
Scan(&title, &content, &source, &tags, &updatedAt)
|
||||
WHERE (e.slug = $1 OR e.id::text = $1)
|
||||
AND ke.deleted_at IS NULL`, idOrSlug).
|
||||
Scan(&title, &content, &source, &editedBy, &tags, &updatedAt, &revisions)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "no knowledge content for entity", "")
|
||||
return
|
||||
@@ -150,8 +156,10 @@ func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request)
|
||||
"title": title,
|
||||
"content": content,
|
||||
"source": source,
|
||||
"edited_by": editedBy,
|
||||
"tags": tags,
|
||||
"updated_at": updatedAt,
|
||||
"revisions": revisions,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,6 +177,7 @@ func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledg
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
JOIN entity_types et ON et.name = e.type
|
||||
WHERE ke.search @@ plainto_tsquery('english', $1)
|
||||
AND ke.deleted_at IS NULL
|
||||
ORDER BY rank DESC
|
||||
LIMIT $2`,
|
||||
q, limit)
|
||||
@@ -232,6 +241,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
|
||||
WHERE target.slug = $1
|
||||
AND r.valid_to IS NULL
|
||||
AND r.type IN ('documents', 'about')
|
||||
AND ke.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
|
||||
FROM knowledge_entities ke
|
||||
@@ -242,6 +252,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
|
||||
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
||||
WHERE r.valid_to IS NULL
|
||||
AND r.type = 'procedure-for'
|
||||
AND ke.deleted_at IS NULL
|
||||
ORDER BY 2`,
|
||||
entitySlug)
|
||||
if err != nil {
|
||||
|
||||
544
internal/httpapi/knowledge_drift.go
Normal file
544
internal/httpapi/knowledge_drift.go
Normal file
@@ -0,0 +1,544 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Drift tooling for the knowledge base — the maintenance half of the wiki.
|
||||
//
|
||||
// These endpoints exist because the knowledge base measurably rots on its
|
||||
// own. Two failure modes are already present in live data:
|
||||
//
|
||||
// - **Duplicate pileup.** upsert_knowledge keys on exact title, so a note
|
||||
// titled "rclone backup live inspection — 2026-07-15 10:08 UTC" and one
|
||||
// titled "... 11:18 UTC" are different notes. A single day of agent
|
||||
// activity produced eight near-identical investigations that should have
|
||||
// been one living page. Nothing surfaced that, so it kept happening.
|
||||
// - **Tag drift.** `oom` and `OOM` were separate tags; so were `422` and
|
||||
// `proton-422`. Each split halves the usefulness of tag navigation, and
|
||||
// neither is visible from any single note.
|
||||
//
|
||||
// normalizeTags (knowledge_write.go) stops new casing splits at the door;
|
||||
// these endpoints clean up what's already there and make the rot visible.
|
||||
|
||||
// serveKnowledgeTags returns the tag index: every tag with its usage count,
|
||||
// plus the distinct casings actually stored. `variants` is the interesting
|
||||
// column — it's how the operator discovers that `oom` and `OOM` are the same
|
||||
// idea filed twice, which no individual note reveals.
|
||||
func (s *Server) serveKnowledgeTags(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT lower(tag) AS norm,
|
||||
count(*) AS uses,
|
||||
array_agg(DISTINCT tag ORDER BY tag) AS variants
|
||||
FROM knowledge_entities ke, unnest(ke.tags) AS tag
|
||||
WHERE ke.deleted_at IS NULL
|
||||
GROUP BY lower(tag)
|
||||
ORDER BY uses DESC, norm`)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type tagRow struct {
|
||||
Tag string `json:"tag"`
|
||||
Uses int `json:"uses"`
|
||||
Variants []string `json:"variants"`
|
||||
// True when the same tag is stored under more than one casing —
|
||||
// the UI badges these as needing a normalize.
|
||||
Split bool `json:"split"`
|
||||
}
|
||||
items := []tagRow{}
|
||||
for rows.Next() {
|
||||
var t tagRow
|
||||
if err := rows.Scan(&t.Tag, &t.Uses, &t.Variants); err != nil {
|
||||
slog.Error("httpapi: knowledge/tags row scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
t.Split = len(t.Variants) > 1
|
||||
items = append(items, t)
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// serveRenameKnowledgeTag rewrites one or more tags to a single target across
|
||||
// every live note — the merge/rename/normalize action behind the tag manager.
|
||||
// Passing several `from` values into one `to` is the merge case
|
||||
// (`{"from":["422","proton-422"],"to":"proton-422"}`); passing one is a plain
|
||||
// rename; passing the mixed-case variants is the normalize case.
|
||||
func (s *Server) serveRenameKnowledgeTag(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
var body struct {
|
||||
From []string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
to := strings.ToLower(strings.TrimSpace(body.To))
|
||||
from := []string{}
|
||||
for _, f := range body.From {
|
||||
if f = strings.TrimSpace(f); f != "" {
|
||||
from = append(from, f)
|
||||
}
|
||||
}
|
||||
if to == "" || len(from) == 0 {
|
||||
writeProblem(w, req, http.StatusBadRequest, "from and to are required", "")
|
||||
return
|
||||
}
|
||||
|
||||
// Rebuild each affected note's tag array: map every `from` member to
|
||||
// `to`, leave everything else alone, then de-duplicate. The dedupe
|
||||
// matters for the merge case — a note tagged both `422` and
|
||||
// `proton-422` would otherwise end up with `proton-422` twice.
|
||||
//
|
||||
// This is a plain UPDATE on knowledge_entities, so trg_knowledge_revision
|
||||
// fires and every affected note gets a revision. A tag merge across 17
|
||||
// notes is exactly the kind of bulk edit worth being able to inspect
|
||||
// afterwards.
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE knowledge_entities ke
|
||||
SET tags = sub.new_tags, updated_at = now()
|
||||
FROM (
|
||||
SELECT k.entity_id,
|
||||
ARRAY(SELECT DISTINCT CASE WHEN lower(t) = ANY($1) THEN $2 ELSE t END
|
||||
FROM unnest(k.tags) AS t) AS new_tags
|
||||
FROM knowledge_entities k
|
||||
WHERE k.deleted_at IS NULL
|
||||
AND EXISTS (SELECT 1 FROM unnest(k.tags) AS t WHERE lower(t) = ANY($1))
|
||||
) AS sub
|
||||
WHERE ke.entity_id = sub.entity_id`,
|
||||
lowerAll(from), to)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "rename failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
_, actorLabel := actorInfo(ctx)
|
||||
slog.Info("knowledge tags renamed", "from", from, "to", to,
|
||||
"notes", tag.RowsAffected(), "actor", actorLabel)
|
||||
writeJSON(w, map[string]any{"ok": true, "notes_updated": tag.RowsAffected()})
|
||||
}
|
||||
|
||||
// serveKnowledgeDuplicates clusters notes whose titles are near-identical.
|
||||
//
|
||||
// Pairwise trigram similarity is computed in SQL (indexed, and the whole
|
||||
// point of pulling in pg_trgm); the grouping is done here in Go. Returning
|
||||
// clusters rather than pairs matters for the real data: the rclone pileup
|
||||
// produces dozens of pairs, which is unreadable, versus one cluster, which
|
||||
// is the actionable unit.
|
||||
//
|
||||
// The grouping uses **complete linkage** — a note joins a cluster only if it
|
||||
// is similar to every member already in it. The obvious implementation
|
||||
// (union-find over the pairs) is single linkage, and on this data it chains
|
||||
// badly: "A~B, B~C" merged notes that were not remotely alike, collapsing
|
||||
// fifteen distinct backup events into one unusable blob. Requiring mutual
|
||||
// similarity keeps clusters tight enough to act on.
|
||||
//
|
||||
// Even so, these are *candidates for review*, never a verdict. The five
|
||||
// "Lifecycle: <verb> a node" runbooks are mutually similar by title and are
|
||||
// five deliberately distinct documents — no threshold distinguishes them
|
||||
// from a genuine duplicate, so merging stays a manual, previewed action.
|
||||
func (s *Server) serveKnowledgeDuplicates(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
// 0.6, tuned against the live data: at 0.45 the "Lifecycle: <verb> a
|
||||
// node" runbooks (five deliberately distinct documents that happen to
|
||||
// share a naming template) formed a false-positive cluster; 0.6 clears
|
||||
// that down to a single borderline pair while keeping every genuine
|
||||
// duplicate cluster (the rclone/apt-audit/uptime pileups) intact.
|
||||
// Tunable per request — the UI exposes this as the review net widens.
|
||||
threshold := 0.6
|
||||
if t := req.URL.Query().Get("threshold"); t != "" {
|
||||
if v, err := strconv.ParseFloat(t, 64); err == nil && v > 0 && v <= 1 {
|
||||
threshold = v
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT a.slug, b.slug, similarity(ka.title, kb.title) AS sim
|
||||
FROM knowledge_entities ka
|
||||
JOIN knowledge_entities kb ON ka.entity_id < kb.entity_id
|
||||
JOIN entities a ON a.id = ka.entity_id
|
||||
JOIN entities b ON b.id = kb.entity_id
|
||||
WHERE ka.deleted_at IS NULL AND kb.deleted_at IS NULL
|
||||
AND similarity(ka.title, kb.title) > $1
|
||||
ORDER BY sim DESC`, threshold)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type pair struct {
|
||||
A, B string
|
||||
Sim float64
|
||||
}
|
||||
pairs := []pair{}
|
||||
for rows.Next() {
|
||||
var p pair
|
||||
if err := rows.Scan(&p.A, &p.B, &p.Sim); err != nil {
|
||||
slog.Error("httpapi: knowledge/duplicates row scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, p)
|
||||
}
|
||||
|
||||
// Complete-linkage grouping. `pairs` arrives sorted by similarity
|
||||
// descending, so each new cluster is seeded from the strongest remaining
|
||||
// pair and then only grows with notes that are similar to *everything*
|
||||
// already inside it.
|
||||
sim := make(map[string]float64, len(pairs)*2)
|
||||
key := func(a, b string) string {
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
return a + "\x00" + b
|
||||
}
|
||||
for _, p := range pairs {
|
||||
sim[key(p.A, p.B)] = p.Sim
|
||||
}
|
||||
linked := func(a, b string) bool { return sim[key(a, b)] > 0 }
|
||||
|
||||
assigned := map[string]bool{}
|
||||
type rawCluster struct {
|
||||
members []string
|
||||
top float64
|
||||
}
|
||||
raw := []rawCluster{}
|
||||
|
||||
for _, p := range pairs {
|
||||
if assigned[p.A] || assigned[p.B] {
|
||||
continue
|
||||
}
|
||||
c := rawCluster{members: []string{p.A, p.B}, top: p.Sim}
|
||||
assigned[p.A], assigned[p.B] = true, true
|
||||
|
||||
// Sweep the remaining pairs for candidates that connect to every
|
||||
// current member. Repeat until a full pass adds nothing, since
|
||||
// admitting one member can qualify another.
|
||||
for grew := true; grew; {
|
||||
grew = false
|
||||
for _, q := range pairs {
|
||||
for _, cand := range []string{q.A, q.B} {
|
||||
if assigned[cand] {
|
||||
continue
|
||||
}
|
||||
ok := true
|
||||
for _, m := range c.members {
|
||||
if !linked(cand, m) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
c.members = append(c.members, cand)
|
||||
assigned[cand] = true
|
||||
grew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
raw = append(raw, c)
|
||||
}
|
||||
|
||||
groups := map[string][]string{}
|
||||
best := map[string]float64{}
|
||||
for _, c := range raw {
|
||||
root := c.members[0]
|
||||
groups[root] = c.members
|
||||
best[root] = c.top
|
||||
}
|
||||
|
||||
// Re-fetch display detail for the clustered slugs only.
|
||||
type member struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
Size int `json:"size"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
EditedBy string `json:"edited_by"`
|
||||
}
|
||||
detail := map[string]member{}
|
||||
if len(groups) > 0 {
|
||||
all := []string{}
|
||||
for _, g := range groups {
|
||||
all = append(all, g...)
|
||||
}
|
||||
drows, derr := s.pool.Query(ctx, `
|
||||
SELECT e.slug, ke.title, e.type, length(ke.content),
|
||||
ke.updated_at::text, COALESCE(ke.edited_by,'')
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE e.slug = ANY($1) AND ke.deleted_at IS NULL`, all)
|
||||
if derr != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "detail query failed", derr.Error())
|
||||
return
|
||||
}
|
||||
defer drows.Close()
|
||||
for drows.Next() {
|
||||
var m member
|
||||
if err := drows.Scan(&m.Slug, &m.Title, &m.Kind, &m.Size, &m.UpdatedAt, &m.EditedBy); err != nil {
|
||||
slog.Error("httpapi: knowledge/duplicates detail scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
detail[m.Slug] = m
|
||||
}
|
||||
}
|
||||
|
||||
type cluster struct {
|
||||
Members []member `json:"members"`
|
||||
TopSim float64 `json:"top_similarity"`
|
||||
TotalSize int `json:"total_size"`
|
||||
}
|
||||
out := []cluster{}
|
||||
for root, slugs := range groups {
|
||||
c := cluster{TopSim: best[root]}
|
||||
for _, sl := range slugs {
|
||||
if m, ok := detail[sl]; ok {
|
||||
c.Members = append(c.Members, m)
|
||||
c.TotalSize += m.Size
|
||||
}
|
||||
}
|
||||
if len(c.Members) < 2 {
|
||||
continue
|
||||
}
|
||||
// Newest first inside a cluster — the most recent note is usually
|
||||
// the one worth keeping as the merge target.
|
||||
sort.Slice(c.Members, func(i, j int) bool {
|
||||
return c.Members[i].UpdatedAt > c.Members[j].UpdatedAt
|
||||
})
|
||||
out = append(out, c)
|
||||
}
|
||||
// Biggest clusters first: an eight-note pileup deserves attention before
|
||||
// a two-note coincidence.
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if len(out[i].Members) != len(out[j].Members) {
|
||||
return len(out[i].Members) > len(out[j].Members)
|
||||
}
|
||||
return out[i].TopSim > out[j].TopSim
|
||||
})
|
||||
|
||||
writeJSON(w, map[string]any{"clusters": out, "threshold": threshold})
|
||||
}
|
||||
|
||||
// serveKnowledgeOrphans surfaces notes that have fallen out of every
|
||||
// navigation path — the ones that are technically present but effectively
|
||||
// unreachable, and so quietly stop being maintained.
|
||||
//
|
||||
// Three independent reasons, reported per note (a note can have several):
|
||||
// - untagged: invisible to tag navigation
|
||||
// - unlinked: not `about` any entity, so it never appears on a machine's page
|
||||
// - stale: untouched for 90+ days
|
||||
func (s *Server) serveKnowledgeOrphans(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
staleDays := 90
|
||||
if d := req.URL.Query().Get("stale_days"); d != "" {
|
||||
if v, err := strconv.Atoi(d); err == nil && v > 0 && v <= 3650 {
|
||||
staleDays = v
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, fmt.Sprintf(`
|
||||
SELECT e.slug, ke.title, e.type, COALESCE(ke.edited_by,''),
|
||||
ke.updated_at::text,
|
||||
(ke.tags IS NULL OR cardinality(ke.tags) = 0) AS untagged,
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM relationships r
|
||||
WHERE r.source_id = ke.entity_id AND r.valid_to IS NULL
|
||||
AND r.type IN ('documents', 'about')
|
||||
) AS unlinked,
|
||||
(ke.updated_at < now() - interval '%d days') AS stale
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ke.deleted_at IS NULL
|
||||
ORDER BY ke.updated_at ASC`, staleDays))
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type orphan struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
EditedBy string `json:"edited_by"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Reasons []string `json:"reasons"`
|
||||
}
|
||||
items := []orphan{}
|
||||
counts := map[string]int{"untagged": 0, "unlinked": 0, "stale": 0}
|
||||
for rows.Next() {
|
||||
var o orphan
|
||||
var untagged, unlinked, stale bool
|
||||
if err := rows.Scan(&o.Slug, &o.Title, &o.Kind, &o.EditedBy, &o.UpdatedAt,
|
||||
&untagged, &unlinked, &stale); err != nil {
|
||||
slog.Error("httpapi: knowledge/orphans row scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
o.Reasons = []string{}
|
||||
if untagged {
|
||||
o.Reasons = append(o.Reasons, "untagged")
|
||||
counts["untagged"]++
|
||||
}
|
||||
if unlinked {
|
||||
o.Reasons = append(o.Reasons, "unlinked")
|
||||
counts["unlinked"]++
|
||||
}
|
||||
if stale {
|
||||
o.Reasons = append(o.Reasons, "stale")
|
||||
counts["stale"]++
|
||||
}
|
||||
if len(o.Reasons) > 0 {
|
||||
items = append(items, o)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{
|
||||
"items": items,
|
||||
"counts": counts,
|
||||
"stale_days": staleDays,
|
||||
})
|
||||
}
|
||||
|
||||
// serveMergeKnowledge folds several notes into one: each source's body is
|
||||
// appended to the target under a provenance heading, the union of all tags is
|
||||
// kept, and the sources are soft-deleted.
|
||||
//
|
||||
// Append rather than discard, and soft-delete rather than hard: a merge is a
|
||||
// judgement call made from a similarity score, and the operator needs to be
|
||||
// able to walk it back. The target's pre-merge state is captured by the
|
||||
// revision trigger, so the merge itself is undoable from the History tab.
|
||||
func (s *Server) serveMergeKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
var body struct {
|
||||
Target string `json:"target"`
|
||||
Sources []string `json:"sources"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Target) == "" || len(body.Sources) == 0 {
|
||||
writeProblem(w, req, http.StatusBadRequest, "target and sources are required", "")
|
||||
return
|
||||
}
|
||||
|
||||
targetID, err := s.resolveKnowledgeEntity(ctx, body.Target)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "target note not found", body.Target)
|
||||
return
|
||||
}
|
||||
_, actorLabel := actorInfo(ctx)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var merged []string
|
||||
var appended strings.Builder
|
||||
tagSet := map[string]bool{}
|
||||
|
||||
for _, srcSlug := range body.Sources {
|
||||
if srcSlug == body.Target {
|
||||
continue // merging a note into itself would duplicate its body
|
||||
}
|
||||
var srcTitle, srcContent, srcUpdated string
|
||||
var srcTags []string
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT ke.title, ke.content, COALESCE(ke.tags,'{}'), ke.updated_at::text
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE (e.slug = $1 OR e.id::text = $1) AND ke.deleted_at IS NULL`,
|
||||
srcSlug).Scan(&srcTitle, &srcContent, &srcTags, &srcUpdated)
|
||||
if err != nil {
|
||||
slog.Warn("knowledge merge: source not found, skipping", "slug", srcSlug)
|
||||
continue
|
||||
}
|
||||
appended.WriteString("\n\n---\n\n## Merged: ")
|
||||
appended.WriteString(srcTitle)
|
||||
appended.WriteString("\n\n*Originally ")
|
||||
appended.WriteString(srcSlug)
|
||||
appended.WriteString(", last updated ")
|
||||
appended.WriteString(srcUpdated)
|
||||
appended.WriteString("*\n\n")
|
||||
appended.WriteString(srcContent)
|
||||
for _, t := range srcTags {
|
||||
tagSet[strings.ToLower(strings.TrimSpace(t))] = true
|
||||
}
|
||||
merged = append(merged, srcSlug)
|
||||
}
|
||||
|
||||
if len(merged) == 0 {
|
||||
writeProblem(w, req, http.StatusBadRequest, "no valid source notes to merge", "")
|
||||
return
|
||||
}
|
||||
|
||||
extraTags := make([]string, 0, len(tagSet))
|
||||
for t := range tagSet {
|
||||
if t != "" {
|
||||
extraTags = append(extraTags, t)
|
||||
}
|
||||
}
|
||||
sort.Strings(extraTags)
|
||||
|
||||
// The array concat + DISTINCT keeps the target's own tags first and adds
|
||||
// only what the sources contribute.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE knowledge_entities
|
||||
SET content = content || $2,
|
||||
tags = ARRAY(SELECT DISTINCT unnest(COALESCE(tags,'{}') || $3::text[])),
|
||||
edited_by = $4,
|
||||
updated_at = now()
|
||||
WHERE entity_id = $1`,
|
||||
targetID, appended.String(), extraTags, actorLabel); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "merge write failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
for _, srcSlug := range merged {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE knowledge_entities ke
|
||||
SET deleted_at = now(), edited_by = $2
|
||||
FROM entities e
|
||||
WHERE e.id = ke.entity_id AND (e.slug = $1 OR e.id::text = $1)`,
|
||||
srcSlug, actorLabel); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "source delete failed", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("knowledge merged", "target", body.Target, "sources", merged, "actor", actorLabel)
|
||||
writeJSON(w, map[string]any{"ok": true, "merged": merged, "tags_added": extraTags})
|
||||
}
|
||||
|
||||
// lowerAll is the case-folding helper the tag queries compare against.
|
||||
func lowerAll(in []string) []string {
|
||||
out := make([]string, len(in))
|
||||
for i, s := range in {
|
||||
out[i] = strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
659
internal/httpapi/knowledge_write.go
Normal file
659
internal/httpapi/knowledge_write.go
Normal file
@@ -0,0 +1,659 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Operator-facing write path for the knowledge base. Until this file, the
|
||||
// only way anything reached knowledge_entities was the MCP tool
|
||||
// upsert_knowledge (internal/mcp/server.go) — an agent-only surface. The web
|
||||
// UI could search and read but never create, correct, or remove a note, so
|
||||
// the operator's own knowledge had nowhere to go and an agent mistake had no
|
||||
// fix short of psql.
|
||||
//
|
||||
// All routes here are non-OpenAPI custom routes, consistent with the existing
|
||||
// knowledge read routes (see the carve-out block in server.go): they trade in
|
||||
// raw markdown and ad-hoc aggregates rather than generated schema types.
|
||||
//
|
||||
// Deletion is soft (deleted_at) — see migrations/022_knowledge_revisions.up.sql
|
||||
// for why — so every read path in this file filters on `ke.deleted_at IS NULL`.
|
||||
|
||||
// knowledgeSlugSegmentRe strips a title down to a single slug segment.
|
||||
// Mirrors knowledgeSlugRe in internal/mcp/server.go; duplicated rather than
|
||||
// exported across the package boundary because the two callers namespace
|
||||
// their output differently (see knowledgeSlugFor).
|
||||
var knowledgeSlugSegmentRe = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// knowledgeSlugFor builds `<kind>:<folder>/<title-slug>`. The MCP tool's
|
||||
// equivalent hardcodes the `nomos/` folder; operator-created notes need to
|
||||
// land somewhere else so the navigator tree can tell at a glance who wrote
|
||||
// what, and so an operator note can never collide with an agent note that
|
||||
// happens to share a title.
|
||||
func knowledgeSlugFor(kind, folder, title string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(title))
|
||||
s = knowledgeSlugSegmentRe.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
if s == "" {
|
||||
s = "note"
|
||||
}
|
||||
if len(s) > 80 {
|
||||
s = s[:80]
|
||||
}
|
||||
folder = strings.Trim(strings.ToLower(strings.TrimSpace(folder)), "/")
|
||||
folder = knowledgeSlugSegmentRe.ReplaceAllString(folder, "-")
|
||||
folder = strings.Trim(folder, "-")
|
||||
if folder == "" {
|
||||
folder = "operator"
|
||||
}
|
||||
return kind + ":" + folder + "/" + s
|
||||
}
|
||||
|
||||
// validKnowledgeKind mirrors the three entity types that knowledge_entities
|
||||
// rows are allowed to hang off (see upsert_knowledge's own check).
|
||||
func validKnowledgeKind(kind string) bool {
|
||||
switch kind {
|
||||
case "document", "investigation", "runbook":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveKnowledgeEntity maps an id-or-slug path segment to the entity id of
|
||||
// a live (non-deleted) knowledge note. Returns pgx.ErrNoRows when there's no
|
||||
// such note, which callers turn into a 404.
|
||||
func (s *Server) resolveKnowledgeEntity(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT ke.entity_id
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE (e.slug = $1 OR e.id::text = $1)
|
||||
AND ke.deleted_at IS NULL`, idOrSlug).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// resolveKnowledgeEntityAny is resolveKnowledgeEntity without the
|
||||
// deleted_at filter — for the one read path (revisions) that must still work
|
||||
// on a deleted note. The whole point of soft-delete is that a note's history
|
||||
// stays inspectable after removal (e.g. to confirm what was lost before
|
||||
// restoring it); requiring the note to be live first would defeat that.
|
||||
func (s *Server) resolveKnowledgeEntityAny(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT ke.entity_id
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE e.slug = $1 OR e.id::text = $1`, idOrSlug).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// pathParam pulls a chi URL param and percent-decodes it. Knowledge slugs
|
||||
// contain both ':' and '/' (e.g. "document:containers/101-jellyfin"), so they
|
||||
// reach the handler still encoded — chi.URLParam does no decoding of its own
|
||||
// on manually-registered routes (unlike the OpenAPI-generated ones, which
|
||||
// decode via runtime.BindStyledParameterWithOptions).
|
||||
func pathParam(req *http.Request, name string) (string, error) {
|
||||
return url.PathUnescape(chi.URLParam(req, name))
|
||||
}
|
||||
|
||||
// serveKnowledgeList returns every live note without its body — the backing
|
||||
// data for the wiki navigator tree. Distinct from /knowledge/recent, which
|
||||
// caps at 200 and exists to answer "what changed lately" for the stats view:
|
||||
// the tree needs the complete set, and needs the linked-entity slugs so it
|
||||
// can offer a group-by-entity arrangement without N+1 fetches.
|
||||
//
|
||||
// Body text is deliberately excluded — with ~100 notes averaging ~1 KB the
|
||||
// full payload would be ~100 KB per app open, to render a list that shows
|
||||
// only titles.
|
||||
func (s *Server) serveKnowledgeList(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
type item struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
Source string `json:"source"`
|
||||
EditedBy string `json:"edited_by"`
|
||||
Tags []string `json:"tags"`
|
||||
About []string `json:"about"`
|
||||
Size int `json:"size"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Revisions int `json:"revisions"`
|
||||
}
|
||||
|
||||
// The `about` aggregate mirrors GetEntityKnowledge's first UNION branch
|
||||
// (documents/about edges) — the 'procedure-for' branch is left out here
|
||||
// because it joins against entity *types* rather than entities and can't
|
||||
// produce a per-note slug list.
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT e.id::text, e.slug, ke.title, e.type, COALESCE(ke.source,''),
|
||||
COALESCE(ke.edited_by,''), COALESCE(ke.tags, '{}'),
|
||||
COALESCE((
|
||||
SELECT array_agg(DISTINCT t.slug)
|
||||
FROM relationships r
|
||||
JOIN entities t ON t.id = r.target_id
|
||||
WHERE r.source_id = ke.entity_id
|
||||
AND r.valid_to IS NULL
|
||||
AND r.type IN ('documents', 'about')
|
||||
), '{}'),
|
||||
length(ke.content),
|
||||
ke.updated_at::text, ke.created_at::text,
|
||||
(SELECT count(*) FROM knowledge_revisions kr WHERE kr.entity_id = ke.entity_id)
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ke.deleted_at IS NULL
|
||||
ORDER BY ke.updated_at DESC`)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []item{}
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.ID, &it.Slug, &it.Title, &it.Kind, &it.Source,
|
||||
&it.EditedBy, &it.Tags, &it.About, &it.Size,
|
||||
&it.UpdatedAt, &it.CreatedAt, &it.Revisions); err != nil {
|
||||
slog.Error("httpapi: knowledge/list row scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// serveKnowledgeTrash lists soft-deleted notes — the counterpart to
|
||||
// serveKnowledgeList, and what the "restore" affordance in the UI browses.
|
||||
// Without this, a deleted note is invisible from every list endpoint
|
||||
// (correctly — they all filter deleted_at) with no way to even discover it
|
||||
// exists to restore.
|
||||
func (s *Server) serveKnowledgeTrash(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT e.slug, ke.title, e.type, COALESCE(ke.edited_by,''), ke.deleted_at::text
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ke.deleted_at IS NOT NULL
|
||||
ORDER BY ke.deleted_at DESC`)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type item struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
DeletedBy string `json:"deleted_by"`
|
||||
DeletedAt string `json:"deleted_at"`
|
||||
}
|
||||
items := []item{}
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.Slug, &it.Title, &it.Kind, &it.DeletedBy, &it.DeletedAt); err != nil {
|
||||
slog.Error("httpapi: knowledge/trash row scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// knowledgeWriteBody is the shared request shape for create and update.
|
||||
// Every field is a pointer so update can distinguish "not supplied" (leave
|
||||
// alone) from "supplied empty" (clear it) — a PUT that only changes tags
|
||||
// must not blank the body.
|
||||
type knowledgeWriteBody struct {
|
||||
Title *string `json:"title"`
|
||||
Content *string `json:"content"`
|
||||
Kind *string `json:"kind"`
|
||||
Tags *[]string `json:"tags"`
|
||||
Folder *string `json:"folder"`
|
||||
About *[]string `json:"about"`
|
||||
}
|
||||
|
||||
// serveCreateKnowledge creates a note plus its backing entity, and links it
|
||||
// to whatever entities it's about.
|
||||
func (s *Server) serveCreateKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
var body knowledgeWriteBody
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(deref(body.Title))
|
||||
content := strings.TrimSpace(deref(body.Content))
|
||||
if title == "" || content == "" {
|
||||
writeProblem(w, req, http.StatusBadRequest, "title and content are required", "")
|
||||
return
|
||||
}
|
||||
kind := deref(body.Kind)
|
||||
if kind == "" {
|
||||
kind = "document"
|
||||
}
|
||||
if !validKnowledgeKind(kind) {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid kind",
|
||||
"kind must be document, investigation, or runbook")
|
||||
return
|
||||
}
|
||||
tags := normalizeTags(derefSlice(body.Tags))
|
||||
slug := knowledgeSlugFor(kind, deref(body.Folder), title)
|
||||
_, actorLabel := actorInfo(ctx)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
docID, _ := uuid.NewV7()
|
||||
// ON CONFLICT covers the soft-deleted case: the entity row survives a
|
||||
// delete, so recreating a note under the same slug must reuse it rather
|
||||
// than fail the unique constraint.
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO entities (id, slug, type, name, attributes)
|
||||
VALUES ($1, $2, $3, $4, '{}')
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now()
|
||||
RETURNING id`, docID, slug, kind, title).Scan(&docID); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "create entity failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Refuse to silently overwrite an existing LIVE note — upsert_knowledge
|
||||
// (the MCP tool) deliberately upserts by title (the agent re-records the
|
||||
// same finding as it learns more), but an operator hitting "create" with
|
||||
// a colliding title almost certainly means to write something new.
|
||||
//
|
||||
// The `WHERE knowledge_entities.deleted_at IS NOT NULL` guard makes this
|
||||
// check atomic with the write, rather than a separate SELECT before it:
|
||||
// a plain pre-check has a TOCTOU race where two concurrent creates of
|
||||
// the same title can both pass the check and then both proceed to
|
||||
// INSERT ON CONFLICT DO UPDATE, silently clobbering each other. Here,
|
||||
// the UPDATE branch only actually applies when the conflicting row is
|
||||
// soft-deleted (a legitimate "resurrect" case). When it isn't, the row
|
||||
// is left untouched, RETURNING yields no row, and pgx.ErrNoRows below
|
||||
// becomes the 409 — the collision can never be missed, no matter how
|
||||
// the two writers interleave.
|
||||
var wroteID uuid.UUID
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO knowledge_entities
|
||||
(entity_id, title, content, source, tags, edited_by, updated_at, deleted_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $4, now(), NULL)
|
||||
ON CONFLICT (entity_id) DO UPDATE
|
||||
SET title = EXCLUDED.title, content = EXCLUDED.content,
|
||||
tags = EXCLUDED.tags, edited_by = EXCLUDED.edited_by,
|
||||
updated_at = now(), deleted_at = NULL
|
||||
WHERE knowledge_entities.deleted_at IS NOT NULL
|
||||
RETURNING entity_id`,
|
||||
docID, title, content, actorLabel, tags).Scan(&wroteID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeProblem(w, req, http.StatusConflict, "a note with this title already exists", slug)
|
||||
return
|
||||
} else if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "write knowledge failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
linked := s.linkKnowledgeAbout(ctx, tx, docID, derefSlice(body.About))
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("knowledge created", "slug", slug, "kind", kind, "actor", actorLabel, "linked", linked)
|
||||
// Content-Type before WriteHeader — setting it after is a no-op, the
|
||||
// status line is already on the wire.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"slug": slug, "id": docID.String(), "linked": linked,
|
||||
}); err != nil {
|
||||
slog.Error("httpapi: json encode failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// serveUpdateKnowledge edits a live note in place. The prior version is
|
||||
// captured by the trg_knowledge_revision trigger, not by this handler — see
|
||||
// the migration for why that lives in the database.
|
||||
//
|
||||
// Note the slug is intentionally NOT recomputed when the title changes:
|
||||
// slugs are the wiki's stable link target ([[slug]] references, relationship
|
||||
// rows, bookmarked window ids), and silently re-slugging on a typo fix would
|
||||
// break every inbound link.
|
||||
func (s *Server) serveUpdateKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
idOrSlug, err := pathParam(req, "id")
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var body knowledgeWriteBody
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
if body.Title == nil && body.Content == nil && body.Tags == nil && body.About == nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "nothing to update",
|
||||
"supply at least one of title, content, tags, about")
|
||||
return
|
||||
}
|
||||
if body.Title != nil && strings.TrimSpace(*body.Title) == "" {
|
||||
writeProblem(w, req, http.StatusBadRequest, "title cannot be empty", "")
|
||||
return
|
||||
}
|
||||
if body.Content != nil && strings.TrimSpace(*body.Content) == "" {
|
||||
writeProblem(w, req, http.StatusBadRequest, "content cannot be empty", "")
|
||||
return
|
||||
}
|
||||
|
||||
entityID, err := s.resolveKnowledgeEntity(ctx, idOrSlug)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
|
||||
return
|
||||
}
|
||||
_, actorLabel := actorInfo(ctx)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// COALESCE keeps unsupplied fields untouched; edited_by and updated_at
|
||||
// always move so the UI can show who last touched it. The trigger only
|
||||
// snapshots when title/content/tags actually differ, so a no-op save
|
||||
// doesn't manufacture a revision.
|
||||
var newTitle *string
|
||||
if body.Title != nil {
|
||||
t := strings.TrimSpace(*body.Title)
|
||||
newTitle = &t
|
||||
}
|
||||
var newContent *string
|
||||
if body.Content != nil {
|
||||
c := strings.TrimSpace(*body.Content)
|
||||
newContent = &c
|
||||
}
|
||||
var newTags *[]string
|
||||
if body.Tags != nil {
|
||||
t := normalizeTags(*body.Tags)
|
||||
newTags = &t
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE knowledge_entities
|
||||
SET title = COALESCE($2, title),
|
||||
content = COALESCE($3, content),
|
||||
tags = COALESCE($4, tags),
|
||||
edited_by = $5,
|
||||
updated_at = now()
|
||||
WHERE entity_id = $1`,
|
||||
entityID, newTitle, newContent, newTags, actorLabel); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "update failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Keep the entity's display name in step with the note title — the graph
|
||||
// and the fleet table read entities.name, and leaving it stale is exactly
|
||||
// the drift this app exists to fight.
|
||||
if newTitle != nil {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE entities SET name = $2, updated_at = now() WHERE id = $1`,
|
||||
entityID, *newTitle); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "rename entity failed", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// About is replace-semantics, not merge: the editor presents the full
|
||||
// link set, so an absent slug means the operator removed it. Existing
|
||||
// edges are closed (valid_to) rather than deleted, preserving history.
|
||||
var linked []string
|
||||
if body.About != nil {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE relationships SET valid_to = now()
|
||||
WHERE source_id = $1 AND valid_to IS NULL AND type IN ('documents', 'about')`,
|
||||
entityID); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "unlink failed", err.Error())
|
||||
return
|
||||
}
|
||||
linked = s.linkKnowledgeAbout(ctx, tx, entityID, *body.About)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("knowledge updated", "entity_id", entityID, "actor", actorLabel)
|
||||
// `linked` lets the caller diff against what it submitted and warn about
|
||||
// any slug that didn't resolve — see linkKnowledgeAbout: a typo'd entity
|
||||
// slug otherwise fails with nothing but a server-side slog.Warn, so the
|
||||
// operator gets no feedback that one of their About links didn't take.
|
||||
writeJSON(w, map[string]any{"ok": true, "linked": linked})
|
||||
}
|
||||
|
||||
// serveDeleteKnowledge soft-deletes a note. The row, its revision trail and
|
||||
// its entity all survive; only the deleted_at stamp changes, and every read
|
||||
// path filters on it.
|
||||
func (s *Server) serveDeleteKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
idOrSlug, err := pathParam(req, "id")
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||
return
|
||||
}
|
||||
entityID, err := s.resolveKnowledgeEntity(ctx, idOrSlug)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
|
||||
return
|
||||
}
|
||||
_, actorLabel := actorInfo(ctx)
|
||||
|
||||
// Snapshot the live version before tombstoning. The trigger fires on
|
||||
// title/content/tags changes only, and a delete changes none of them —
|
||||
// without this the most recent version would be the one version missing
|
||||
// from the history if the note is later restored.
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO knowledge_revisions
|
||||
(entity_id, title, content, source, tags, edited_by, version_at)
|
||||
SELECT entity_id, title, content, source, tags, COALESCE(edited_by,''), updated_at
|
||||
FROM knowledge_entities WHERE entity_id = $1`, entityID); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "snapshot failed", err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
UPDATE knowledge_entities SET deleted_at = now(), edited_by = $2
|
||||
WHERE entity_id = $1`, entityID, actorLabel); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "delete failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("knowledge deleted", "entity_id", entityID, "actor", actorLabel)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// serveRestoreKnowledge undoes a soft delete. The counterpart to
|
||||
// serveDeleteKnowledge — without it, "recoverable by clearing the column"
|
||||
// (see the migration) would only be true via psql, which isn't a real
|
||||
// recovery path for an operator using the wiki.
|
||||
func (s *Server) serveRestoreKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
idOrSlug, err := pathParam(req, "id")
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||
return
|
||||
}
|
||||
entityID, err := s.resolveKnowledgeEntityAny(ctx, idOrSlug)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
|
||||
return
|
||||
}
|
||||
_, actorLabel := actorInfo(ctx)
|
||||
|
||||
ct, err := s.pool.Exec(ctx, `
|
||||
UPDATE knowledge_entities SET deleted_at = NULL, edited_by = $2
|
||||
WHERE entity_id = $1 AND deleted_at IS NOT NULL`, entityID, actorLabel)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "restore failed", err.Error())
|
||||
return
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
writeProblem(w, req, http.StatusConflict, "note is not deleted", "")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("knowledge restored", "entity_id", entityID, "actor", actorLabel)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// serveKnowledgeRevisions returns the note's superseded versions, newest
|
||||
// first. Bodies are included: revisions are small (~1 KB) and few, and the
|
||||
// diff view needs both sides anyway — paginating would cost a round trip per
|
||||
// comparison to save nothing.
|
||||
func (s *Server) serveKnowledgeRevisions(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
idOrSlug, err := pathParam(req, "id")
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||
return
|
||||
}
|
||||
entityID, err := s.resolveKnowledgeEntityAny(ctx, idOrSlug)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, title, content, COALESCE(edited_by,''), COALESCE(tags,'{}'),
|
||||
version_at::text, revised_at::text
|
||||
FROM knowledge_revisions
|
||||
WHERE entity_id = $1
|
||||
ORDER BY version_at DESC`, entityID)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type revision struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
EditedBy string `json:"edited_by"`
|
||||
Tags []string `json:"tags"`
|
||||
VersionAt string `json:"version_at"`
|
||||
RevisedAt string `json:"revised_at"`
|
||||
}
|
||||
items := []revision{}
|
||||
for rows.Next() {
|
||||
var r revision
|
||||
if err := rows.Scan(&r.ID, &r.Title, &r.Content, &r.EditedBy, &r.Tags,
|
||||
&r.VersionAt, &r.RevisedAt); err != nil {
|
||||
slog.Error("httpapi: knowledge/revisions row scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
items = append(items, r)
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// linkKnowledgeAbout points a note at the entities it concerns, skipping
|
||||
// slugs that don't resolve and edges that already exist. Returns the slugs
|
||||
// actually linked so the caller can report what stuck — a typo'd slug is a
|
||||
// silent no-op otherwise.
|
||||
func (s *Server) linkKnowledgeAbout(ctx context.Context, tx pgx.Tx, docID uuid.UUID, slugs []string) []string {
|
||||
linked := []string{}
|
||||
for _, raw := range slugs {
|
||||
slug := strings.TrimSpace(raw)
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
var targetID uuid.UUID
|
||||
if err := tx.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&targetID); err != nil {
|
||||
slog.Warn("knowledge: about slug not found, skipping", "slug", slug)
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT $1, $2, 'about', '{"by":"operator"}'::jsonb, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = $1 AND target_id = $2 AND type = 'about' AND valid_to IS NULL)`,
|
||||
docID, targetID); err != nil {
|
||||
slog.Warn("knowledge: link failed", "slug", slug, "error", err)
|
||||
continue
|
||||
}
|
||||
linked = append(linked, slug)
|
||||
}
|
||||
return linked
|
||||
}
|
||||
|
||||
// normalizeTags trims, lowercases and de-duplicates while preserving order.
|
||||
// Lowercasing is the fix for the casing drift already in the data — `oom`
|
||||
// and `OOM` were separate tags on separate notes, so neither tag page showed
|
||||
// the full set. Applied on every write so the split can't reopen.
|
||||
func normalizeTags(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, t := range in {
|
||||
t = strings.ToLower(strings.TrimSpace(t))
|
||||
if t == "" || seen[t] {
|
||||
continue
|
||||
}
|
||||
seen[t] = true
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deref(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func derefSlice(p *[]string) []string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// writeJSON is the success-path counterpart to writeProblem, so the handlers
|
||||
// in this file don't each repeat the header/encode dance.
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("httpapi: json encode failed", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,16 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
||||
// /api/v1/events/stream — in OpenAPI but re-registered for SSE Flush()
|
||||
// /api/v1/knowledge/recent — ad-hoc aggregation, no schema type yet
|
||||
// /api/v1/knowledge/content/{id} — returns raw markdown, not a gen type
|
||||
// /api/v1/knowledge/list — full tree listing, ad-hoc aggregate
|
||||
// /api/v1/knowledge (POST) — markdown in, no gen type
|
||||
// /api/v1/knowledge/content/{id} (PUT/DELETE) — markdown in, soft delete
|
||||
// /api/v1/knowledge/trash — soft-deleted notes, ad-hoc
|
||||
// /api/v1/knowledge/restore/{id} — undo a soft delete, no gen type
|
||||
// /api/v1/knowledge/revisions/{id} — version history, no schema type
|
||||
// /api/v1/knowledge/tags{,/rename} — tag index + bulk rewrite
|
||||
// /api/v1/knowledge/duplicates — trigram clustering, ad-hoc
|
||||
// /api/v1/knowledge/orphans — derived maintenance view
|
||||
// /api/v1/knowledge/merge — bulk fold-in, ad-hoc
|
||||
// /api/v1/activity/recent — recency-ordered, not paginated
|
||||
// /api/v1/activity/session/{id} — session-scoped aggregation
|
||||
// /api/v1/learning/timeline — derived view, no backing schema type
|
||||
@@ -202,6 +212,30 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
||||
// (See "Non-OpenAPI routes" carve-out block above.)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/content/{id}", s.serveKnowledgeContent)
|
||||
|
||||
// Custom (non-OpenAPI) routes: the operator-facing knowledge CRUD surface
|
||||
// (see internal/httpapi/knowledge_write.go) and the drift tooling (see
|
||||
// knowledge_drift.go). Before these, knowledge could only be written by
|
||||
// the agent through the MCP upsert_knowledge tool — the web UI had no way
|
||||
// to create, correct or retire a note.
|
||||
//
|
||||
// Registered on the base router rather than through the OpenAPI codegen
|
||||
// for the same reason as the read routes above: they trade in raw
|
||||
// markdown and ad-hoc aggregates, not generated schema types.
|
||||
// (See "Non-OpenAPI routes" carve-out block above.)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/list", s.serveKnowledgeList)
|
||||
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge", s.serveCreateKnowledge)
|
||||
r.With(combinedAuth(cfg, false)).Put("/api/v1/knowledge/content/{id}", s.serveUpdateKnowledge)
|
||||
r.With(combinedAuth(cfg, false)).Delete("/api/v1/knowledge/content/{id}", s.serveDeleteKnowledge)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/trash", s.serveKnowledgeTrash)
|
||||
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/restore/{id}", s.serveRestoreKnowledge)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/revisions/{id}", s.serveKnowledgeRevisions)
|
||||
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/tags", s.serveKnowledgeTags)
|
||||
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/tags/rename", s.serveRenameKnowledgeTag)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/duplicates", s.serveKnowledgeDuplicates)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/orphans", s.serveKnowledgeOrphans)
|
||||
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/merge", s.serveMergeKnowledge)
|
||||
|
||||
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
|
||||
// unlike ListExecutions which sorts by target for pagination) and the
|
||||
// per-session "what did this session do" digest.
|
||||
|
||||
119
migrations/022_knowledge_revisions.up.sql
Normal file
119
migrations/022_knowledge_revisions.up.sql
Normal file
@@ -0,0 +1,119 @@
|
||||
-- 022_knowledge_revisions.up.sql
|
||||
-- Version history for knowledge_entities, so an edit can never be silently lost.
|
||||
--
|
||||
-- The concrete hazard this closes: the MCP tool `upsert_knowledge`
|
||||
-- (internal/mcp/server.go) keys on title and does
|
||||
-- `ON CONFLICT (entity_id) DO UPDATE SET content = EXCLUDED.content` —
|
||||
-- unconditionally. Before this migration, an operator hand-editing a note in
|
||||
-- the web UI would have that edit overwritten with no trace the next time
|
||||
-- Nomos re-upserted a note with the same title. There was no history table
|
||||
-- and no way to recover the prior body.
|
||||
--
|
||||
-- The snapshot is a BEFORE UPDATE **trigger** rather than application-level
|
||||
-- code in the HTTP handler, specifically because there are two independent
|
||||
-- writers: the web API (new in this change) and the MCP tool the agent uses.
|
||||
-- App-level snapshotting would only cover whichever path remembered to call
|
||||
-- it. A trigger covers both, plus any future writer and any manual psql fix.
|
||||
--
|
||||
-- Each row in knowledge_revisions is a *superseded* version: the state of the
|
||||
-- note before the update that displaced it. The current version always lives
|
||||
-- in knowledge_entities, never here, so "history" is
|
||||
-- knowledge_entities + knowledge_revisions ordered by version_at DESC.
|
||||
|
||||
-- Who authored the version currently in knowledge_entities. Distinct from
|
||||
-- `source`, which is overloaded: it holds either 'nomos-agent' (written via
|
||||
-- MCP) or a seed file path ('containers/101-jellyfin') and is NOT updated on
|
||||
-- conflict, so a seeded doc later rewritten by the agent still reports its
|
||||
-- original file path. edited_by answers the question the UI actually asks —
|
||||
-- "did a human or the agent last touch this?" — without disturbing source,
|
||||
-- which the seeding logic still relies on.
|
||||
ALTER TABLE knowledge_entities
|
||||
ADD COLUMN IF NOT EXISTS edited_by TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Backfill: every existing row's last writer is whatever source says. For
|
||||
-- agent-written notes that's exactly right; for seeded notes it records the
|
||||
-- seed path, which is the honest answer (no human has edited them yet).
|
||||
UPDATE knowledge_entities
|
||||
SET edited_by = COALESCE(source, '')
|
||||
WHERE edited_by = '';
|
||||
|
||||
-- Soft delete. A hard DELETE would cascade knowledge_revisions away with the
|
||||
-- entity, which contradicts the point of this migration — removing a note is
|
||||
-- exactly the moment its history matters most. Deleting sets deleted_at; all
|
||||
-- read paths filter it out, the revision trail survives, and an accidental
|
||||
-- delete is recoverable by clearing the column.
|
||||
ALTER TABLE knowledge_entities
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- Partial index: every list/search/read query carries `deleted_at IS NULL`,
|
||||
-- and deleted notes are expected to stay a small minority.
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_live
|
||||
ON knowledge_entities (updated_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_revisions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
entity_id UUID NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
source TEXT,
|
||||
tags TEXT[],
|
||||
edited_by TEXT NOT NULL DEFAULT '',
|
||||
-- When this version was written (the superseded row's updated_at).
|
||||
version_at TIMESTAMPTZ NOT NULL,
|
||||
-- When it was replaced. version_at of revision N and revised_at of
|
||||
-- revision N-1 bracket how long that version was the live one.
|
||||
revised_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- The only access pattern: "show me the history of this note, newest first."
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_revisions_entity
|
||||
ON knowledge_revisions (entity_id, version_at DESC);
|
||||
|
||||
-- Snapshot the outgoing row whenever the substance changes. Deliberately
|
||||
-- ignores updated_at-only touches: upsert_knowledge sets `updated_at = now()`
|
||||
-- on every call even when re-writing byte-identical content (it has no
|
||||
-- change detection), and without this guard a re-run of the same agent task
|
||||
-- would pile up identical revisions and bury the real edits.
|
||||
--
|
||||
-- `search` is a GENERATED column and is intentionally not carried into
|
||||
-- revisions — it is derived from title+content and would be dead weight.
|
||||
CREATE OR REPLACE FUNCTION snapshot_knowledge_revision() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF OLD.title IS DISTINCT FROM NEW.title
|
||||
OR OLD.content IS DISTINCT FROM NEW.content
|
||||
OR OLD.tags IS DISTINCT FROM NEW.tags THEN
|
||||
INSERT INTO knowledge_revisions
|
||||
(entity_id, title, content, source, tags, edited_by, version_at)
|
||||
VALUES
|
||||
(OLD.entity_id, OLD.title, OLD.content, OLD.source, OLD.tags,
|
||||
OLD.edited_by, OLD.updated_at);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- DROP + CREATE rather than CREATE OR REPLACE: Postgres 16 has no
|
||||
-- CREATE OR REPLACE TRIGGER for this form, and the migration must stay
|
||||
-- re-runnable.
|
||||
DROP TRIGGER IF EXISTS trg_knowledge_revision ON knowledge_entities;
|
||||
|
||||
CREATE TRIGGER trg_knowledge_revision
|
||||
BEFORE UPDATE ON knowledge_entities
|
||||
FOR EACH ROW EXECUTE FUNCTION snapshot_knowledge_revision();
|
||||
|
||||
-- Trigram similarity, for the duplicate-detection view. The knowledge base
|
||||
-- has already accumulated near-duplicates that exact matching cannot catch —
|
||||
-- four separate "rclone backup live inspection — <date>" investigations, each
|
||||
-- a fresh note where an update to the existing one was meant. upsert_knowledge
|
||||
-- keys on exact title, so a date suffix is enough to fork a new note.
|
||||
--
|
||||
-- similarity() over titles is what lets the UI cluster those and offer a
|
||||
-- merge. fuzzystrmatch (levenshtein) was the alternative; trigram wins here
|
||||
-- because these titles differ by whole appended words rather than typos, and
|
||||
-- because it comes with a GIN index while levenshtein cannot be indexed.
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_title_trgm
|
||||
ON knowledge_entities USING gin (title gin_trgm_ops)
|
||||
WHERE deleted_at IS NULL;
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "vega",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "zinc"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "vega",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "zinc"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -6,7 +6,10 @@
|
||||
<title>Oikos</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Inknut+Antiqua:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Inknut+Antiqua:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
|
||||
@@ -15,11 +18,20 @@
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
(function(){try{var t=localStorage.getItem('oikos-theme');if(!t){t=window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'}
|
||||
if(t==='dark')document.documentElement.classList.add('dark')}catch(e){}})()
|
||||
;(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('oikos-theme')
|
||||
if (!t) {
|
||||
t = window.matchMedia('(prefers-color-scheme:light)').matches ? 'light' : 'dark'
|
||||
}
|
||||
if (t === 'dark') document.documentElement.classList.add('dark')
|
||||
} catch (e) {}
|
||||
})()
|
||||
</script>
|
||||
<script src="/wails/runtime.js"></script>
|
||||
<script>window.__OIKOS_CONFIG__ = {};</script>
|
||||
<script>
|
||||
window.__OIKOS_CONFIG__ = {}
|
||||
</script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
11
web/package-lock.json
generated
11
web/package-lock.json
generated
@@ -18,7 +18,8 @@
|
||||
"uplot": "^1.6.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lucide/svelte": "^1.23.0",
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@lucide/svelte": "^1.25.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tsconfig/svelte": "^5.0.0",
|
||||
@@ -870,7 +871,6 @@
|
||||
"integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
@@ -921,9 +921,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@lucide/svelte": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.23.0.tgz",
|
||||
"integrity": "sha512-3LQbKXx9vId6Nx4E2Nu2qwgJfdmr5+CVeVJbxe5cy+HcnCRd9QVVtZXqvgBYAV1OJrPmQAf9/3gJWLCpASC/Ng==",
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz",
|
||||
"integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
@@ -1378,7 +1378,6 @@
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lucide/svelte": "^1.23.0",
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@lucide/svelte": "^1.25.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tsconfig/svelte": "^5.0.0",
|
||||
|
||||
122
web/src/app.css
122
web/src/app.css
@@ -2,6 +2,17 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* bits-ui components (Slider, and any future orientation/disabled-aware
|
||||
primitive) style themselves via shorthand data-* variants that Tailwind
|
||||
v4 doesn't ship — it only auto-generates variants for bare boolean data
|
||||
attributes (data-disabled), not attribute=value pairs like
|
||||
data-orientation="horizontal". Without these, e.g. Slider's track silently
|
||||
collapses to 0 height (no h-1.5 class survives), leaving only the thumb
|
||||
visible with no visible rail. */
|
||||
@custom-variant data-horizontal (&[data-orientation='horizontal']);
|
||||
@custom-variant data-vertical (&[data-orientation='vertical']);
|
||||
@custom-variant data-disabled (&[data-disabled]);
|
||||
|
||||
@theme inline {
|
||||
--font-sans: 'DM Sans', system-ui, sans-serif;
|
||||
--font-mono: 'DM Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
@@ -73,7 +84,7 @@
|
||||
--chart-3: oklch(0.5 0.08 30);
|
||||
--chart-4: oklch(0.6 0.06 90);
|
||||
--chart-5: oklch(0.4 0.04 45);
|
||||
--sidebar: oklch(0.90 0.025 55);
|
||||
--sidebar: oklch(0.9 0.025 55);
|
||||
--sidebar-foreground: oklch(0.18 0.03 45);
|
||||
--sidebar-primary: oklch(0.55 0.14 45);
|
||||
--sidebar-primary-foreground: oklch(0.95 0.02 55);
|
||||
@@ -148,7 +159,6 @@
|
||||
--accent-orange: var(--warning);
|
||||
}
|
||||
|
||||
|
||||
/* Terminal-style block cursor — outside @layer so it overrides CodeMirror */
|
||||
.cm-cursor,
|
||||
.cm-cursor-primary {
|
||||
@@ -170,7 +180,12 @@
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
@@ -254,17 +269,7 @@
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
pointer-events: auto;
|
||||
/* Frosted glass — same idea as the desktop's "What should Nomos do?"
|
||||
launcher card (bg-card/70 backdrop-blur), tuned less transparent
|
||||
(85%, not 70%) because backdrop-filter's blur strength isn't
|
||||
consistent across engines — Firefox blurs noticeably less than
|
||||
Chromium at the same radius, so a Chromium-tuned opacity reads as
|
||||
"way too see-through" there (2026-07-21). Leaning on a higher base
|
||||
opacity keeps windows legible everywhere; the blur is a bonus on
|
||||
top, not what's carrying the effect. */
|
||||
background: color-mix(in oklab, var(--card) 85%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
background: var(--card);
|
||||
color: var(--card-foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -328,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;
|
||||
}
|
||||
|
||||
@@ -112,7 +112,11 @@ export async function fetchQuestions(sessionId: string): Promise<SessionQuestion
|
||||
return data.questions ?? []
|
||||
}
|
||||
|
||||
export async function answerQuestion(sessionId: string, questionId: string, answer: string): Promise<boolean> {
|
||||
export async function answerQuestion(
|
||||
sessionId: string,
|
||||
questionId: string,
|
||||
answer: string
|
||||
): Promise<boolean> {
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ answer })
|
||||
@@ -133,42 +137,45 @@ export function streamChat(
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message, session_id: sessionId ?? undefined }),
|
||||
signal: controller.signal
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
onError(`HTTP ${res.status}`)
|
||||
return
|
||||
}
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
onError('no response body')
|
||||
return
|
||||
}
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
onError(`HTTP ${res.status}`)
|
||||
return
|
||||
}
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
onError('no response body')
|
||||
return
|
||||
}
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const ev: ChatEvent = JSON.parse(line.slice(6))
|
||||
onEvent(ev)
|
||||
} catch {
|
||||
// skip malformed
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const ev: ChatEvent = JSON.parse(line.slice(6))
|
||||
onEvent(ev)
|
||||
} catch {
|
||||
// skip malformed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch((err) => {
|
||||
onError(err.message)
|
||||
}).finally(() => {
|
||||
onDone()
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
onError(err.message)
|
||||
})
|
||||
.finally(() => {
|
||||
onDone()
|
||||
})
|
||||
|
||||
return controller
|
||||
}
|
||||
@@ -291,7 +298,9 @@ export interface EventFilters {
|
||||
severity?: string
|
||||
}
|
||||
|
||||
export async function fetchEvents(filters: EventFilters = {}): Promise<import('./stores/events').OikosEvent[]> {
|
||||
export async function fetchEvents(
|
||||
filters: EventFilters = {}
|
||||
): Promise<import('./stores/events').OikosEvent[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.type) params.set('type', filters.type)
|
||||
if (filters.severity) params.set('severity', filters.severity)
|
||||
@@ -483,7 +492,9 @@ export interface Signal {
|
||||
last_seen_at: string
|
||||
}
|
||||
|
||||
export async function fetchSignals(filters: { state?: string; severity?: string } = {}): Promise<Signal[]> {
|
||||
export async function fetchSignals(
|
||||
filters: { state?: string; severity?: string } = {}
|
||||
): Promise<Signal[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.state) params.set('state', filters.state)
|
||||
if (filters.severity) params.set('severity', filters.severity)
|
||||
@@ -509,7 +520,11 @@ export async function resolveSignal(id: string, note?: string): Promise<Signal |
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function muteSignal(id: string, muteUntil: string, note?: string): Promise<Signal | null> {
|
||||
export async function muteSignal(
|
||||
id: string,
|
||||
muteUntil: string,
|
||||
note?: string
|
||||
): Promise<Signal | null> {
|
||||
const res = await fetchWithAuth(`${API}/signals/${id}/mute`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mute_until: muteUntil, note })
|
||||
@@ -533,7 +548,9 @@ export interface Relationship {
|
||||
// reachable going forward from here), this hits a dedicated endpoint that
|
||||
// matches on source_id OR target_id directly.
|
||||
export async function fetchEntityRelations(id: string): Promise<Relationship[]> {
|
||||
const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}/relations?direction=both`)
|
||||
const res = await fetchWithAuth(
|
||||
`${API}/entities/${encodeURIComponent(id)}/relations?direction=both`
|
||||
)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -658,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
|
||||
@@ -671,7 +690,262 @@ export async function fetchKnowledgeContent(id: string): Promise<KnowledgeConten
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchEntityEvents(entityId: string): Promise<import('./stores/events').OikosEvent[]> {
|
||||
// ─── 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[]> {
|
||||
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
|
||||
const res = await fetchWithAuth(`${API}/events?${params}`)
|
||||
if (!res.ok) return []
|
||||
@@ -718,13 +992,19 @@ export async function fetchEntityTasks(entity: Entity): Promise<EntityTask[]> {
|
||||
tasks.map(async (task): Promise<EntityTask | null> => {
|
||||
const g = await fetchGraph({ root: task.slug, depth: 1 })
|
||||
if (!g) return null
|
||||
const involvesThisEntity = g.edges.some((e) => e.type === 'involves' && e.target === entity.slug)
|
||||
const involvesThisEntity = g.edges.some(
|
||||
(e) => e.type === 'involves' && e.target === entity.slug
|
||||
)
|
||||
const nodeTypeById = new Map(g.nodes.map((n) => [n.id, n.type]))
|
||||
const idBySlug = new Map(g.nodes.map((n) => [n.slug, n.id]))
|
||||
const executionCount = g.edges.filter((e) => {
|
||||
if (e.type !== 'involves') return false
|
||||
const targetId = idBySlug.get(e.target)
|
||||
return targetId != null && nodeTypeById.get(targetId) === 'execution' && executionIds.has(targetId)
|
||||
return (
|
||||
targetId != null &&
|
||||
nodeTypeById.get(targetId) === 'execution' &&
|
||||
executionIds.has(targetId)
|
||||
)
|
||||
}).length
|
||||
if (!involvesThisEntity && executionCount === 0) return null
|
||||
return { task, executionCount }
|
||||
@@ -755,7 +1035,11 @@ export async function fetchChecksForTarget(targetSlug: string): Promise<Check[]>
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export async function patchCheck(id: string, version: number, patch: { enabled?: boolean; interval_s?: number; timeout_s?: number }): Promise<Check | null> {
|
||||
export async function patchCheck(
|
||||
id: string,
|
||||
version: number,
|
||||
patch: { enabled?: boolean; interval_s?: number; timeout_s?: number }
|
||||
): Promise<Check | null> {
|
||||
const res = await fetchWithAuth(`${API}/checks/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'If-Match': `"${version}"` },
|
||||
@@ -781,12 +1065,14 @@ export interface AgentActivity {
|
||||
correlation_id?: string | null
|
||||
}
|
||||
|
||||
export async function fetchAgentActivity(filters: {
|
||||
agent_id?: string
|
||||
activity_type?: string
|
||||
entity_id?: string
|
||||
limit?: number
|
||||
} = {}): Promise<AgentActivity[]> {
|
||||
export async function fetchAgentActivity(
|
||||
filters: {
|
||||
agent_id?: string
|
||||
activity_type?: string
|
||||
entity_id?: string
|
||||
limit?: number
|
||||
} = {}
|
||||
): Promise<AgentActivity[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.agent_id) params.set('agent_id', filters.agent_id)
|
||||
if (filters.activity_type) params.set('activity_type', filters.activity_type)
|
||||
@@ -821,14 +1107,16 @@ export interface AuditEntry {
|
||||
correlation_id?: string | null
|
||||
}
|
||||
|
||||
export async function fetchAudit(filters: {
|
||||
actor_type?: string
|
||||
actor_id?: string
|
||||
entity_id?: string
|
||||
action?: string
|
||||
correlation_id?: string
|
||||
limit?: number
|
||||
} = {}): Promise<AuditEntry[]> {
|
||||
export async function fetchAudit(
|
||||
filters: {
|
||||
actor_type?: string
|
||||
actor_id?: string
|
||||
entity_id?: string
|
||||
action?: string
|
||||
correlation_id?: string
|
||||
limit?: number
|
||||
} = {}
|
||||
): Promise<AuditEntry[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.actor_type) params.set('actor_type', filters.actor_type)
|
||||
if (filters.actor_id) params.set('actor_id', filters.actor_id)
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
class="min-h-0 flex-1 resize-none rounded-md border bg-background p-3 font-mono text-sm leading-relaxed focus-visible:outline-2 focus-visible:outline-ring"
|
||||
></textarea>
|
||||
<p class="shrink-0 text-xs text-muted-foreground">
|
||||
A demo installable app — uninstall it from the App Store to remove its
|
||||
icon and window. Its notes persist in localStorage under
|
||||
A demo installable app — uninstall it from the App Store to remove its icon and window. Its
|
||||
notes persist in localStorage under
|
||||
<code class="font-mono">{storageKey}</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -23,9 +23,14 @@ import type { Component } from 'svelte'
|
||||
import { writable, derived, get, type Readable } from 'svelte/store'
|
||||
import type { DashboardSummary } from '$lib/api'
|
||||
import { openSignalCount } from '$lib/stores/context'
|
||||
import { catalogById, type AppManifest, type AppPermission, type CatalogEntry } from '$lib/app-store/catalog'
|
||||
import {
|
||||
catalogById,
|
||||
type AppManifest,
|
||||
type AppPermission,
|
||||
type CatalogEntry
|
||||
} from '$lib/app-store/catalog'
|
||||
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
||||
import DatabaseIcon from '@lucide/svelte/icons/database'
|
||||
import BoxesIcon from '@lucide/svelte/icons/boxes'
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||
import SirenIcon from '@lucide/svelte/icons/siren'
|
||||
import SearchIcon from '@lucide/svelte/icons/search'
|
||||
@@ -88,9 +93,11 @@ export const builtinApps: AppDef[] = [
|
||||
source: 'builtin'
|
||||
},
|
||||
{
|
||||
// id stays 'kb' so persisted window geometry / desktop-icon position /
|
||||
// the 'oikos-kb-view' preference survive the rename to "Fleet".
|
||||
id: 'kb',
|
||||
title: 'Knowledge Base',
|
||||
icon: DatabaseIcon,
|
||||
title: 'Fleet',
|
||||
icon: BoxesIcon,
|
||||
component: () => import('../pages/KnowledgeBase.svelte'),
|
||||
width: 1000,
|
||||
height: 700,
|
||||
@@ -235,8 +242,9 @@ export const apps: Readable<AppDef[]> = derived(installedIds, (ids) => {
|
||||
return [...builtinApps, ...installed]
|
||||
})
|
||||
|
||||
export const appById: Readable<Map<string, AppDef>> = derived(apps, (list) =>
|
||||
new Map(list.map((a) => [a.id, a]))
|
||||
export const appById: Readable<Map<string, AppDef>> = derived(
|
||||
apps,
|
||||
(list) => new Map(list.map((a) => [a.id, a]))
|
||||
)
|
||||
|
||||
// Install/uninstall. Idempotent — installing an already-installed app or
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// Browsing categories for the Knowledge Base — a coarser, more useful axis
|
||||
// than the ontology's own `layer` (infrastructure/governance/cognition),
|
||||
// which lumps very different things (an LXC and a DNS record and a storage
|
||||
// volume) into one "infrastructure" bucket. Built from the ontology's
|
||||
// `domain` field instead, which already draws these lines; this just
|
||||
// groups the domains into browsing-sized buckets. The Knowledge Base shows
|
||||
// every entity at once now (filtered by the type multiselect, not by a
|
||||
// fetch-time category), but "fleet" still names the default type selection.
|
||||
export type Category = 'network' | 'fleet' | 'identity' | 'knowledge'
|
||||
|
||||
// entity_types.domain -> Category. `external` folds into Network (isp-link,
|
||||
// domain-registration are network-adjacent); `physical`, `software`, and
|
||||
// `storage` fold into Fleet (ups/sensor/site support compute, services/apps
|
||||
// nest under the compute entity that provides them, and pools/volumes/
|
||||
// datasets nest under their compute entity or pool, all via EntityTable's
|
||||
// treegrid) — browsing them separately fragments "what's running where".
|
||||
// `meta` (the abstract root "entity" type) and `cognition` (see
|
||||
// KNOWLEDGE_TYPES below) are handled outside this map.
|
||||
const DOMAIN_TO_CATEGORY: Record<string, Category> = {
|
||||
network: 'network',
|
||||
external: 'network',
|
||||
compute: 'fleet',
|
||||
physical: 'fleet',
|
||||
software: 'fleet',
|
||||
storage: 'fleet',
|
||||
identity: 'identity'
|
||||
}
|
||||
|
||||
// `cognition` is not one thing: document/investigation/runbook are genuine
|
||||
// long-form knowledge, but the domain also holds execution/check/task/
|
||||
// signal/approval/pattern/skill/classification/feedback — operational
|
||||
// telemetry with its own pages (Operations, Signals, Learning). Mapping the
|
||||
// whole domain to Knowledge pulled in 245 execution + 25 check entities that
|
||||
// fan out to a handful of compute nodes via `targets`/`checks` edges,
|
||||
// flooding the graph. Only the true knowledge types get a category; the
|
||||
// rest are excluded from Knowledge Base browsing entirely (returns
|
||||
// undefined, same treatment as the abstract `entity` root type).
|
||||
const KNOWLEDGE_TYPES = new Set(['document', 'investigation', 'runbook'])
|
||||
|
||||
export function typeToCategory(type: string, domain: string): Category | undefined {
|
||||
if (KNOWLEDGE_TYPES.has(type)) return 'knowledge'
|
||||
if (domain === 'cognition') return undefined
|
||||
return DOMAIN_TO_CATEGORY[domain]
|
||||
}
|
||||
128
web/src/lib/components/AgentTrace.svelte
Normal file
128
web/src/lib/components/AgentTrace.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
// The agent's working trace for one assistant turn: the live "thinking"
|
||||
// indicator and that turn's tool calls merged into a single collapsible
|
||||
// strip, instead of a stack of one card per call (a 13-call turn buried the
|
||||
// actual answer). Collapsed it's one line — the current activity while
|
||||
// running, a count once finished. Expanded it lists what the agent did, in
|
||||
// humanized language, each row opening to its raw args/result.
|
||||
import type { ToolCallResult } from '$lib/types'
|
||||
import ToolCallCard from './ToolCallCard.svelte'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||
|
||||
let {
|
||||
tools = [],
|
||||
label = null,
|
||||
status = 'idle'
|
||||
}: {
|
||||
tools?: ToolCallResult[]
|
||||
/** Live indicator text — the running step, an error, or "Done". */
|
||||
label?: string | null
|
||||
/** `idle` = no live state; the strip is just this turn's finished trace. */
|
||||
status?: 'running' | 'done' | 'error' | 'idle'
|
||||
} = $props()
|
||||
|
||||
let expanded = $state(false)
|
||||
|
||||
const count = $derived(tools.length)
|
||||
// Collapsed line: prefer the live activity while something is happening,
|
||||
// otherwise summarize the turn so a finished trace still says what it was.
|
||||
const headline = $derived.by(() => {
|
||||
if (status !== 'idle' && label) return label
|
||||
if (count > 0) return count === 1 ? '1 tool call' : `${count} tool calls`
|
||||
return 'No tool calls'
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="trace rounded-lg border border-border/60 bg-card/40 transition-colors"
|
||||
class:running={status === 'running'}
|
||||
>
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-muted/40"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? 'Hide agent trace' : 'Show agent trace'}
|
||||
>
|
||||
<span
|
||||
class="shrink-0 {status === 'error'
|
||||
? 'text-destructive'
|
||||
: status === 'idle'
|
||||
? 'text-muted-foreground'
|
||||
: 'text-primary'}"
|
||||
>
|
||||
{#if status === 'running'}
|
||||
<Spinner class="size-3" />
|
||||
{:else if status === 'error'}
|
||||
<XIcon class="size-3" />
|
||||
{:else if status === 'done'}
|
||||
<CheckIcon class="size-3" />
|
||||
{:else}
|
||||
<SparklesIcon class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-xs {status === 'error'
|
||||
? 'text-destructive'
|
||||
: status === 'running'
|
||||
? 'text-foreground/80'
|
||||
: 'text-muted-foreground'}"
|
||||
>
|
||||
{headline}
|
||||
</span>
|
||||
|
||||
{#if count > 0 && status !== 'idle'}
|
||||
<span class="shrink-0 text-[10px] tabular-nums text-muted-foreground/60">{count}</span>
|
||||
{/if}
|
||||
|
||||
<ChevronRightIcon
|
||||
class="size-3 shrink-0 text-muted-foreground/50 transition-transform {expanded
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="border-t border-border/40 p-1">
|
||||
{#if count > 0}
|
||||
{#each tools as tool (tool.id)}
|
||||
<ToolCallCard {tool} />
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="px-2 py-1.5 text-[11px] text-muted-foreground">
|
||||
Nothing recorded for this turn yet.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.trace {
|
||||
animation: trace-in 0.2s ease-out;
|
||||
}
|
||||
/* A faint pulse while the agent is mid-turn — the collapsed strip is the
|
||||
only thing on screen then, so it carries the "still working" signal. */
|
||||
.trace.running {
|
||||
border-color: color-mix(in oklab, var(--primary) 35%, var(--border));
|
||||
}
|
||||
@keyframes trace-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trace {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,12 +9,9 @@
|
||||
import type { Readable } from 'svelte/store'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import ToolCallCard from './ToolCallCard.svelte'
|
||||
import AgentTrace from './AgentTrace.svelte'
|
||||
import OperatorQuestion from './OperatorQuestion.svelte'
|
||||
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
|
||||
import SquareIcon from '@lucide/svelte/icons/square'
|
||||
import { marked } from 'marked'
|
||||
@@ -63,10 +60,16 @@
|
||||
let wasStreaming = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (streaming) { indicatorDone = false; wasStreaming = true }
|
||||
if (streaming) {
|
||||
indicatorDone = false
|
||||
wasStreaming = true
|
||||
}
|
||||
if (!streaming && wasStreaming) {
|
||||
indicatorDone = true
|
||||
const t = setTimeout(() => { indicatorDone = false; wasStreaming = false }, 3000)
|
||||
const t = setTimeout(() => {
|
||||
indicatorDone = false
|
||||
wasStreaming = false
|
||||
}, 3000)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
})
|
||||
@@ -94,7 +97,10 @@
|
||||
const lineHeight = parseFloat(taCs.lineHeight)
|
||||
if (!Number.isFinite(lineHeight)) return
|
||||
const taBoxY =
|
||||
parseFloat(taCs.paddingTop) + parseFloat(taCs.paddingBottom) + parseFloat(taCs.borderTopWidth) + parseFloat(taCs.borderBottomWidth)
|
||||
parseFloat(taCs.paddingTop) +
|
||||
parseFloat(taCs.paddingBottom) +
|
||||
parseFloat(taCs.borderTopWidth) +
|
||||
parseFloat(taCs.borderBottomWidth)
|
||||
// The wrapper's own padding/border (space around the textarea, not part
|
||||
// of it) also has to fit inside the minimum, or the textarea gets
|
||||
// squeezed below one line once the pane is dragged down to it.
|
||||
@@ -147,7 +153,9 @@
|
||||
}
|
||||
renderer.table = function (token) {
|
||||
const header = token.header.map((c: { text: string }) => `<th>${c.text}</th>`).join('')
|
||||
const body = token.rows.map((r: { text: string }[]) => `<tr>${r.map((c) => `<td>${c.text}</td>`).join('')}</tr>`).join('')
|
||||
const body = token.rows
|
||||
.map((r: { text: string }[]) => `<tr>${r.map((c) => `<td>${c.text}</td>`).join('')}</tr>`)
|
||||
.join('')
|
||||
return `<div class="table-wrapper"><table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table></div>`
|
||||
}
|
||||
return DOMPurify.sanitize(marked.parse(text, { async: false, renderer }) as string)
|
||||
@@ -184,125 +192,174 @@
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 min-w-0 flex-col" bind:clientHeight={threadHeight}>
|
||||
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1" on:resize={() => (userResizedInput = true)}>
|
||||
<Splitpanes
|
||||
horizontal
|
||||
theme="oikos-theme"
|
||||
dblClickSplitter={false}
|
||||
class="min-h-0 flex-1"
|
||||
on:resize={() => (userResizedInput = true)}
|
||||
>
|
||||
<Pane class="flex flex-col">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
||||
<div class="mx-auto flex min-h-full max-w-3xl flex-col gap-5 p-4">
|
||||
{#if messages.length === 0}
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-6 text-center">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Nomos</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Your resident operator. Ask about the fleet, or tell it to act.</p>
|
||||
</div>
|
||||
{#if suggestions.length}
|
||||
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{#each suggestions as q}
|
||||
<Button variant="outline" size="sm" class="h-auto justify-start whitespace-normal py-2 text-left text-xs" onclick={() => ask(q)}>
|
||||
{q}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each messages as msg, idx (msg.id)}
|
||||
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
||||
{#if msg.role === 'user'}
|
||||
<div class="flex items-baseline gap-2 px-1">
|
||||
<span class="text-[10px] font-medium text-muted-foreground/70">You</span>
|
||||
{#if msg.created_at}
|
||||
<span class="text-[9px] text-muted-foreground/50">{formatTime(msg.created_at)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg">{msg.text}</div>
|
||||
{:else}
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="flex items-baseline gap-2 px-1">
|
||||
<span class="text-[10px] font-medium text-muted-foreground/70">Nomos</span>
|
||||
{#if msg.created_at}
|
||||
<span class="text-[9px] text-muted-foreground/50">{formatTime(msg.created_at)}</span>
|
||||
{/if}
|
||||
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
||||
<div class="mx-auto flex min-h-full max-w-3xl flex-col gap-5 p-4">
|
||||
{#if messages.length === 0}
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-6 text-center">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Nomos</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Your resident operator. Ask about the fleet, or tell it to act.
|
||||
</p>
|
||||
</div>
|
||||
{#if msg.text}
|
||||
<div class="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 idx === messages.length - 1 && streaming}
|
||||
<span class="stream-cursor" aria-hidden="true"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if msg.tools.length > 0}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each msg.tools as tool (tool.id)}
|
||||
<ToolCallCard {tool} />
|
||||
{#if suggestions.length}
|
||||
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{#each suggestions as q}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-auto justify-start whitespace-normal py-2 text-left text-xs"
|
||||
onclick={() => ask(q)}
|
||||
>
|
||||
{q}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if idx === messages.length - 1 && msg.text === '' && (streaming || indicatorDone || error)}
|
||||
<div class="flex items-center gap-2 py-1 text-xs {error ? 'text-destructive' : indicatorDone ? 'text-primary' : 'text-muted-foreground'}">
|
||||
{#if error}
|
||||
<XIcon class="size-3 shrink-0" />
|
||||
{:else if indicatorDone}
|
||||
<CheckIcon class="size-3 shrink-0" />
|
||||
{:else}
|
||||
<Spinner class="size-3 shrink-0 text-primary" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each messages as msg, idx (msg.id)}
|
||||
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
||||
{#if msg.role === 'user'}
|
||||
<div class="flex items-baseline gap-2 px-1">
|
||||
<span class="text-[10px] font-medium text-muted-foreground/70">You</span>
|
||||
{#if msg.created_at}
|
||||
<span class="text-[9px] text-muted-foreground/50"
|
||||
>{formatTime(msg.created_at)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg"
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
{:else}
|
||||
{@const isLast = idx === messages.length - 1}
|
||||
{@const traceStatus = !isLast
|
||||
? 'idle'
|
||||
: error
|
||||
? 'error'
|
||||
: streaming
|
||||
? 'running'
|
||||
: indicatorDone
|
||||
? 'done'
|
||||
: 'idle'}
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="flex items-baseline gap-2 px-1">
|
||||
<span class="text-[10px] font-medium text-muted-foreground/70">Nomos</span>
|
||||
{#if msg.created_at}
|
||||
<span class="text-[9px] text-muted-foreground/50"
|
||||
>{formatTime(msg.created_at)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- The working trace sits above the answer: it's what happened
|
||||
first, and collapsed it keeps a long tool run from burying
|
||||
the text below it. -->
|
||||
{#if msg.tools.length > 0 || traceStatus !== 'idle'}
|
||||
<AgentTrace
|
||||
tools={msg.tools}
|
||||
status={traceStatus}
|
||||
label={traceStatus === 'idle' ? null : indicatorLabel}
|
||||
/>
|
||||
{/if}
|
||||
{#if msg.text}
|
||||
<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}
|
||||
<span class="stream-cursor" aria-hidden="true"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<span>{indicatorLabel}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if question}
|
||||
<OperatorQuestion {sessionId} {question} />
|
||||
{/if}
|
||||
<div bind:this={messagesEnd}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if connectionState === 'disconnected'}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div
|
||||
class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs"
|
||||
>
|
||||
<RefreshCwIcon class="size-3 shrink-0" aria-hidden="true" />
|
||||
<span class="text-warning-foreground flex-1"
|
||||
>Agent connection lost. The task may still be running.</span
|
||||
>
|
||||
<Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={onReconnect}
|
||||
>Reconnect</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else if connectionState === 'reconnecting'}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-xs">
|
||||
<RefreshCwIcon
|
||||
class="size-3 shrink-0 animate-spin text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-muted-foreground flex-1">Reconnecting to agent…</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div
|
||||
class="mb-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each chatErrors as err (err.id)}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div
|
||||
class="mb-2 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
<span class="flex-1">{err.message}</span>
|
||||
{#if err.action}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
class="h-6 text-[11px]"
|
||||
onclick={() => onDismissError(err.id)}>{err.action}</Button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
class="ml-1 text-muted-foreground hover:text-foreground"
|
||||
onclick={() => onDismissError(err.id)}
|
||||
aria-label="Dismiss">×</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if question}
|
||||
<OperatorQuestion {sessionId} {question} />
|
||||
{/if}
|
||||
<div bind:this={messagesEnd}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if connectionState === 'disconnected'}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs">
|
||||
<RefreshCwIcon class="size-3 shrink-0" aria-hidden="true" />
|
||||
<span class="text-warning-foreground flex-1">Agent connection lost. The task may still be running.</span>
|
||||
<Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={onReconnect}>Reconnect</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if connectionState === 'reconnecting'}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-xs">
|
||||
<RefreshCwIcon class="size-3 shrink-0 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
<span class="text-muted-foreground flex-1">Reconnecting to agent…</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each chatErrors as err (err.id)}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<span class="flex-1">{err.message}</span>
|
||||
{#if err.action}
|
||||
<Button size="xs" variant="ghost" class="h-6 text-[11px]" onclick={() => onDismissError(err.id)}>{err.action}</Button>
|
||||
{/if}
|
||||
<button class="ml-1 text-muted-foreground hover:text-foreground" onclick={() => onDismissError(err.id)} aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</Pane>
|
||||
|
||||
<Pane bind:size={inputSize} minSize={inputMinSize} maxSize={45} class="flex flex-col">
|
||||
<div class="flex h-full min-h-0 flex-col border-t bg-card/50 p-3 input-ornament relative" bind:this={inputWrapperRef}>
|
||||
<div
|
||||
class="flex h-full min-h-0 flex-col border-t bg-card/50 p-3 input-ornament relative"
|
||||
bind:this={inputWrapperRef}
|
||||
>
|
||||
<form
|
||||
class="relative mx-auto flex h-full w-full max-w-3xl"
|
||||
onsubmit={(e) => {
|
||||
@@ -361,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 {
|
||||
@@ -418,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;
|
||||
@@ -469,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;
|
||||
@@ -485,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;
|
||||
}
|
||||
@@ -516,7 +543,13 @@
|
||||
border: none;
|
||||
height: 1px;
|
||||
margin: 0.75rem 0;
|
||||
background: linear-gradient(to right, transparent, var(--border) 20%, var(--border) 80%, transparent);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--border) 20%,
|
||||
var(--border) 80%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* Bold is emphasis, not color — dark weight reads cleanly and lets the
|
||||
@@ -526,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;
|
||||
@@ -561,7 +593,9 @@
|
||||
border-radius: 0.375rem;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
color 0.15s;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -588,7 +622,12 @@
|
||||
}
|
||||
|
||||
@keyframes cursor-blink {
|
||||
0%, 100% { opacity: 0.75; }
|
||||
50% { opacity: 0; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
let canvas = $state<HTMLCanvasElement | null>(null)
|
||||
let particles: Particle[] = []
|
||||
let mouse = { x: -500, y: -500 }
|
||||
let w = 0, h = 0, dpr = 1
|
||||
let w = 0,
|
||||
h = 0,
|
||||
dpr = 1
|
||||
let timer: ReturnType<typeof setTimeout> | 0 = 0
|
||||
|
||||
function spawn() {
|
||||
@@ -73,15 +75,15 @@
|
||||
// update + draw particles
|
||||
for (const p of particles) {
|
||||
// autonomous drift
|
||||
p.vx += (Math.sin(t * 0.4 + p.phase) * 0.003) * 0.15
|
||||
p.vy += (Math.cos(t * 0.35 + p.phase) * 0.003) * 0.15
|
||||
p.vx += Math.sin(t * 0.4 + p.phase) * 0.003 * 0.15
|
||||
p.vy += Math.cos(t * 0.35 + p.phase) * 0.003 * 0.15
|
||||
|
||||
// mouse interaction
|
||||
const dx = p.x - mouse.x
|
||||
const dy = p.y - mouse.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
if (dist < MOUSE_RADIUS && dist > 0) {
|
||||
const force = (MOUSE_RADIUS - dist) / MOUSE_RADIUS * MOUSE_FORCE
|
||||
const force = ((MOUSE_RADIUS - dist) / MOUSE_RADIUS) * MOUSE_FORCE
|
||||
p.vx += (dx / dist) * force * 0.6
|
||||
p.vy += (dy / dist) * force * 0.6
|
||||
}
|
||||
@@ -104,9 +106,7 @@
|
||||
|
||||
// pulse brightness
|
||||
const alpha = p.pulse * (0.35 + 0.15 * Math.sin(t * 1.2 + p.phase))
|
||||
ctx.fillStyle = dark
|
||||
? `rgba(140,175,230,${alpha})`
|
||||
: `rgba(60,90,140,${alpha})`
|
||||
ctx.fillStyle = dark ? `rgba(140,175,230,${alpha})` : `rgba(60,90,140,${alpha})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
@@ -123,9 +123,7 @@
|
||||
const dist = dx * dx + dy * dy
|
||||
if (dist < CONNECT_DIST * CONNECT_DIST) {
|
||||
const alpha = (1 - Math.sqrt(dist) / CONNECT_DIST) * 0.18
|
||||
ctx.strokeStyle = dark
|
||||
? `rgba(140,175,230,${alpha})`
|
||||
: `rgba(60,90,140,${alpha})`
|
||||
ctx.strokeStyle = dark ? `rgba(140,175,230,${alpha})` : `rgba(60,90,140,${alpha})`
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(a.x, a.y)
|
||||
ctx.lineTo(b.x, b.y)
|
||||
@@ -135,7 +133,8 @@
|
||||
}
|
||||
|
||||
// radial scrim to keep center legible
|
||||
const cx = w / 2, cy = h / 2
|
||||
const cx = w / 2,
|
||||
cy = h / 2
|
||||
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
|
||||
const base = dark ? '13,17,23' : '255,255,255'
|
||||
scrim.addColorStop(0, `rgba(${base},0.72)`)
|
||||
|
||||
@@ -22,14 +22,20 @@
|
||||
</script>
|
||||
|
||||
<Collapsible.Root bind:open class="rounded-md border bg-card">
|
||||
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-2 py-1 text-left hover:bg-muted/50">
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-2 py-1 text-left hover:bg-muted/50"
|
||||
>
|
||||
<span class="text-xs font-medium">{title}{count !== undefined ? ` (${count})` : ''}</span>
|
||||
<ChevronDownIcon
|
||||
class="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open ? 'rotate-180' : ''}"
|
||||
class="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in">
|
||||
<Collapsible.Content
|
||||
class="overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in"
|
||||
>
|
||||
<div class="border-t px-2 py-1.5">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
</script>
|
||||
|
||||
<tr>
|
||||
<td {colspan} class={['py-8 text-center text-muted-foreground', className].filter(Boolean).join(' ')}>
|
||||
<td
|
||||
{colspan}
|
||||
class={['py-8 text-center text-muted-foreground', className].filter(Boolean).join(' ')}
|
||||
>
|
||||
{message}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -62,7 +62,9 @@
|
||||
// (source OR target = entity, both directions), so a plain split by which
|
||||
// side matches is enough — no risk of an unrelated sibling-to-sibling edge
|
||||
// sneaking into either group.
|
||||
const outgoingRelations = $derived(entity ? relations.filter((r) => r.source === entity!.slug) : [])
|
||||
const outgoingRelations = $derived(
|
||||
entity ? relations.filter((r) => r.source === entity!.slug) : []
|
||||
)
|
||||
const incomingRelations = $derived(
|
||||
entity ? relations.filter((r) => r.target === entity!.slug && r.source !== entity!.slug) : []
|
||||
)
|
||||
@@ -203,13 +205,23 @@
|
||||
body?: string
|
||||
}
|
||||
|
||||
const LONG_TEXT_KEYS = new Set(['description', 'content', 'summary', 'notes', 'note', 'body', 'details'])
|
||||
const LONG_TEXT_KEYS = new Set([
|
||||
'description',
|
||||
'content',
|
||||
'summary',
|
||||
'notes',
|
||||
'note',
|
||||
'body',
|
||||
'details'
|
||||
])
|
||||
|
||||
function isChangelog(value: unknown): value is ChangelogEntry[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((v) => v && typeof v === 'object' && !Array.isArray(v) && ('title' in v || 'body' in v))
|
||||
value.every(
|
||||
(v) => v && typeof v === 'object' && !Array.isArray(v) && ('title' in v || 'body' in v)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -263,14 +275,22 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
||||
<dt class="shrink-0 text-muted-foreground">State</dt>
|
||||
<dd>{#if entity.state}<Badge>{entity.state}</Badge>{:else}<span class="text-muted-foreground">—</span>{/if}</dd>
|
||||
<dd>
|
||||
{#if entity.state}<Badge>{entity.state}</Badge>{:else}<span
|
||||
class="text-muted-foreground">—</span
|
||||
>{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
||||
<dt class="shrink-0 text-muted-foreground">Health</dt>
|
||||
<dd>
|
||||
{#if entity.health}
|
||||
<span class="flex items-center gap-1.5" title="checked {relativeTime(entity.last_check_at)}">
|
||||
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"></span>
|
||||
<span
|
||||
class="flex items-center gap-1.5"
|
||||
title="checked {relativeTime(entity.last_check_at)}"
|
||||
>
|
||||
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"
|
||||
></span>
|
||||
{entity.health} · checked {relativeTime(entity.last_check_at)}
|
||||
</span>
|
||||
{:else}
|
||||
@@ -286,7 +306,11 @@
|
||||
<dt class="shrink-0 text-muted-foreground">Created</dt>
|
||||
<dd title={entity.created_at}>{relativeTime(entity.created_at)}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 {entity.maintenance_until ? 'border-b pb-1' : ''}">
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 {entity.maintenance_until
|
||||
? 'border-b pb-1'
|
||||
: ''}"
|
||||
>
|
||||
<dt class="shrink-0 text-muted-foreground">Updated</dt>
|
||||
<dd title={entity.updated_at}>{relativeTime(entity.updated_at)}</dd>
|
||||
</div>
|
||||
@@ -313,7 +337,9 @@
|
||||
onclick={() => toggleCheck(check)}
|
||||
title={check.enabled ? 'Click to disable' : 'Click to enable'}
|
||||
>
|
||||
<Badge variant={check.enabled ? 'default' : 'secondary'}>{check.enabled ? 'enabled' : 'disabled'}</Badge>
|
||||
<Badge variant={check.enabled ? 'default' : 'secondary'}
|
||||
>{check.enabled ? 'enabled' : 'disabled'}</Badge
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -324,8 +350,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}
|
||||
@@ -339,7 +367,9 @@
|
||||
{#if row.kind === 'long-text'}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="font-mono text-muted-foreground">{row.key}</p>
|
||||
<p class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-1.5">{row.value}</p>
|
||||
<p class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-1.5">
|
||||
{row.value}
|
||||
</p>
|
||||
</div>
|
||||
{:else if row.kind === 'changelog'}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
@@ -348,10 +378,16 @@
|
||||
{#each row.value as entry}
|
||||
<div class="rounded-sm border-l-2 border-muted-foreground/30 pl-1.5">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
{#if entry.date}<span class="shrink-0 font-mono text-muted-foreground">{entry.date}</span>{/if}
|
||||
{#if entry.date}<span class="shrink-0 font-mono text-muted-foreground"
|
||||
>{entry.date}</span
|
||||
>{/if}
|
||||
{#if entry.title}<span class="font-medium">{entry.title}</span>{/if}
|
||||
</div>
|
||||
{#if entry.body}<p class="whitespace-pre-wrap break-words text-muted-foreground">{entry.body}</p>{/if}
|
||||
{#if entry.body}<p
|
||||
class="whitespace-pre-wrap break-words text-muted-foreground"
|
||||
>
|
||||
{entry.body}
|
||||
</p>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -373,7 +409,12 @@
|
||||
<dt class="shrink-0 font-mono text-muted-foreground">{row.key}</dt>
|
||||
<dd class="min-w-0 flex-1 break-words text-right">
|
||||
{#if row.value !== null && typeof row.value === 'object'}
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-words text-left">{JSON.stringify(row.value, null, 2)}</pre>
|
||||
<pre
|
||||
class="overflow-x-auto whitespace-pre-wrap break-words text-left">{JSON.stringify(
|
||||
row.value,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
{:else}
|
||||
{String(row.value)}
|
||||
{/if}
|
||||
@@ -390,13 +431,27 @@
|
||||
{#snippet relationRow(rel: Relationship)}
|
||||
<div class="flex min-w-0 items-center gap-1 font-mono text-xs">
|
||||
{#if onSelectEntity}
|
||||
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={rel.source} onclick={() => onSelectEntity(rel.source)}>{truncateMiddle(rel.source)}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground"
|
||||
title={rel.source}
|
||||
onclick={() => onSelectEntity(rel.source)}>{truncateMiddle(rel.source)}</button
|
||||
>
|
||||
<span class="shrink-0 text-muted-foreground">—{rel.type}→</span>
|
||||
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={rel.target} onclick={() => onSelectEntity(rel.target)}>{truncateMiddle(rel.target)}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground"
|
||||
title={rel.target}
|
||||
onclick={() => onSelectEntity(rel.target)}>{truncateMiddle(rel.target)}</button
|
||||
>
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate" title={rel.source}>{truncateMiddle(rel.source)}</span>
|
||||
<span class="min-w-0 flex-1 truncate" title={rel.source}
|
||||
>{truncateMiddle(rel.source)}</span
|
||||
>
|
||||
<span class="shrink-0 text-muted-foreground">—{rel.type}→</span>
|
||||
<span class="min-w-0 flex-1 truncate" title={rel.target}>{truncateMiddle(rel.target)}</span>
|
||||
<span class="min-w-0 flex-1 truncate" title={rel.target}
|
||||
>{truncateMiddle(rel.target)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -408,7 +463,11 @@
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if outgoingRelations.length}
|
||||
<div>
|
||||
<div class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">Outgoing ({outgoingRelations.length})</div>
|
||||
<div
|
||||
class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
Outgoing ({outgoingRelations.length})
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each outgoingRelations as rel}
|
||||
{@render relationRow(rel)}
|
||||
@@ -418,7 +477,11 @@
|
||||
{/if}
|
||||
{#if incomingRelations.length}
|
||||
<div>
|
||||
<div class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">Incoming ({incomingRelations.length})</div>
|
||||
<div
|
||||
class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
Incoming ({incomingRelations.length})
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each incomingRelations as rel}
|
||||
{@render relationRow(rel)}
|
||||
@@ -492,21 +555,32 @@
|
||||
{#snippet tasksContent()}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each tasks as { task, executionCount } (task.id)}
|
||||
{@const title = typeof task.attributes?.title === 'string' ? task.attributes.title : task.name}
|
||||
{@const outcome = typeof task.attributes?.outcome === 'string' ? task.attributes.outcome : undefined}
|
||||
<div class="flex items-center justify-between gap-2 border-b pb-1 text-xs last:border-0 last:pb-0">
|
||||
{@const title =
|
||||
typeof task.attributes?.title === 'string' ? task.attributes.title : task.name}
|
||||
{@const outcome =
|
||||
typeof task.attributes?.outcome === 'string' ? task.attributes.outcome : undefined}
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 border-b pb-1 text-xs last:border-0 last:pb-0"
|
||||
>
|
||||
{#if onSelectEntity}
|
||||
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={title} onclick={() => onSelectEntity(task.slug)}>
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground"
|
||||
{title}
|
||||
onclick={() => onSelectEntity(task.slug)}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate" title={title}>{title}</span>
|
||||
<span class="min-w-0 flex-1 truncate" {title}>{title}</span>
|
||||
{/if}
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{#if outcome}
|
||||
<Badge variant={outcome === 'success' ? 'default' : 'destructive'}>{outcome}</Badge>
|
||||
{/if}
|
||||
<Badge variant="outline">{executionCount} action{executionCount === 1 ? '' : 's'}</Badge>
|
||||
<Badge variant="outline"
|
||||
>{executionCount} action{executionCount === 1 ? '' : 's'}</Badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -545,8 +619,12 @@
|
||||
{#each agentActivity as activity (activity.id)}
|
||||
<div class="flex flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-muted-foreground">{new Date(activity.ts).toLocaleString()}</span>
|
||||
<Badge variant={activity.success === false ? 'destructive' : 'outline'}>{activity.activity_type}</Badge>
|
||||
<span class="font-mono text-muted-foreground"
|
||||
>{new Date(activity.ts).toLocaleString()}</span
|
||||
>
|
||||
<Badge variant={activity.success === false ? 'destructive' : 'outline'}
|
||||
>{activity.activity_type}</Badge
|
||||
>
|
||||
</div>
|
||||
<span class="truncate text-muted-foreground"
|
||||
>{activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''}</span
|
||||
@@ -563,10 +641,14 @@
|
||||
{#each auditEntries as entry (entry.id)}
|
||||
<div class="flex flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-muted-foreground">{new Date(entry.ts).toLocaleString()}</span>
|
||||
<span class="font-mono text-muted-foreground"
|
||||
>{new Date(entry.ts).toLocaleString()}</span
|
||||
>
|
||||
<Badge variant="outline">{entry.actor_type}</Badge>
|
||||
</div>
|
||||
<span class="truncate text-muted-foreground">{entry.actor_id ?? '—'} · {entry.action}</span>
|
||||
<span class="truncate text-muted-foreground"
|
||||
>{entry.actor_id ?? '—'} · {entry.action}</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No audit entries.</p>
|
||||
@@ -575,17 +657,34 @@
|
||||
{/snippet}
|
||||
|
||||
{@const sections = [
|
||||
...(ownContent ? [{ key: 'content', title: 'Content', count: 1, content: contentContent }] : []),
|
||||
...(ownContent
|
||||
? [{ key: 'content', title: 'Content', count: 1, content: contentContent }]
|
||||
: []),
|
||||
{ key: 'details', title: 'Details', count: 1, content: detailsContent },
|
||||
{ key: 'monitoring', title: 'Monitoring', count: checks.length, content: monitoringContent },
|
||||
{ key: 'attributes', title: 'Attributes', count: Object.keys(entity.attributes ?? {}).length, content: attributesContent },
|
||||
{ key: 'relations', title: 'Relations', count: outgoingRelations.length + incomingRelations.length, content: relationsContent },
|
||||
{
|
||||
key: 'attributes',
|
||||
title: 'Attributes',
|
||||
count: Object.keys(entity.attributes ?? {}).length,
|
||||
content: attributesContent
|
||||
},
|
||||
{
|
||||
key: 'relations',
|
||||
title: 'Relations',
|
||||
count: outgoingRelations.length + incomingRelations.length,
|
||||
content: relationsContent
|
||||
},
|
||||
{ key: 'metrics', title: 'Metrics', count: metrics.length, content: metricsContent },
|
||||
{ key: 'signals', title: 'Signals', count: signals.length, content: signalsContent },
|
||||
{ key: 'tasks', title: 'Tasks', count: tasks.length, content: tasksContent },
|
||||
{ key: 'knowledge', title: 'Knowledge', count: knowledge.length, content: knowledgeContent },
|
||||
{ key: 'events', title: 'Recent events', count: events.length, content: eventsContent },
|
||||
{ key: 'agentActivity', title: 'Agent activity', count: agentActivity.length, content: agentActivityContent },
|
||||
{
|
||||
key: 'agentActivity',
|
||||
title: 'Agent activity',
|
||||
count: agentActivity.length,
|
||||
content: agentActivityContent
|
||||
},
|
||||
{ key: 'audit', title: 'Audit trail', count: auditEntries.length, content: auditContent }
|
||||
].sort((a, b) => (b.count > 0 ? 1 : 0) - (a.count > 0 ? 1 : 0))}
|
||||
|
||||
@@ -596,67 +695,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>
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
|
||||
import { fetchGraph, type GraphView, type Entity, type Health } from '$lib/api'
|
||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
|
||||
export interface GraphInfo {
|
||||
allRelTypes: string[]
|
||||
relColors: Map<string, string>
|
||||
visibleCount: number
|
||||
truncated: boolean
|
||||
zoomPct: number
|
||||
}
|
||||
|
||||
let {
|
||||
selectedSlug = null,
|
||||
onSelect,
|
||||
root = $bindable(''),
|
||||
depth,
|
||||
search,
|
||||
reloadToken,
|
||||
resetToken,
|
||||
// Owned by the parent (shared with the entity table's type filter) —
|
||||
// this graph only reads it to decide what's in focus, never writes it.
|
||||
activeNodeTypes,
|
||||
activeRelTypes = $bindable(new Set<string>()),
|
||||
info = $bindable<GraphInfo>({ allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
|
||||
}: {
|
||||
selectedSlug?: string | null
|
||||
onSelect: (slug: string | null) => void
|
||||
root?: string
|
||||
depth: number
|
||||
search: string
|
||||
// Bumped by the parent toolbar to request a data reload / view reset —
|
||||
// these controls live in the shared page toolbar (not squeezed inside
|
||||
// this resizable pane), so they can't call load()/resetView() directly.
|
||||
reloadToken: number
|
||||
resetToken: number
|
||||
activeNodeTypes: Set<string>
|
||||
activeRelTypes?: Set<string>
|
||||
info?: GraphInfo
|
||||
} = $props()
|
||||
|
||||
interface Node extends Entity {
|
||||
x?: number
|
||||
y?: number
|
||||
vx?: number
|
||||
vy?: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
degree: number
|
||||
}
|
||||
interface Link {
|
||||
source: string | Node
|
||||
target: string | Node
|
||||
type: string
|
||||
}
|
||||
|
||||
// SVG ids are document-global, not scoped to this <svg> — see
|
||||
// SessionGraph.svelte's dotGridId for why this needs a per-instance suffix
|
||||
// (also covers the per-relationship-type arrow markers below, which were
|
||||
// keyed only by type name and would collide the same way across two
|
||||
// mounted EntityGraph instances).
|
||||
const uid = crypto.randomUUID().slice(0, 8)
|
||||
const dotGridId = `dot-grid-${uid}`
|
||||
|
||||
let graph = $state<GraphView | null>(null)
|
||||
let loading = $state(true)
|
||||
let nodes = $state<Node[]>([])
|
||||
let links = $state<Link[]>([])
|
||||
let sim: Simulation<Node, Link> | null = null
|
||||
|
||||
let hoveredId = $state<string | null>(null)
|
||||
|
||||
// viewport transform: translate(x, y) scale(k)
|
||||
let view = $state({ x: 0, y: 0, k: 1 })
|
||||
let svgEl = $state<SVGSVGElement | null>(null)
|
||||
|
||||
const width = 1200
|
||||
const height = 800
|
||||
|
||||
const healthColor: Record<Health, string> = {
|
||||
healthy: '#3fb950',
|
||||
degraded: '#d29922',
|
||||
down: '#f85149',
|
||||
unknown: '#8b949e'
|
||||
}
|
||||
|
||||
const relPalette = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#39c5cf', '#f0883e', '#db61a2']
|
||||
const relColorByType = $derived.by(() => {
|
||||
const map = new Map<string, string>()
|
||||
const types = Array.from(new Set(links.map((l) => l.type))).sort()
|
||||
types.forEach((t, i) => map.set(t, relPalette[i % relPalette.length]))
|
||||
return map
|
||||
})
|
||||
|
||||
function relColor(type: string): string {
|
||||
return relColorByType.get(type) ?? '#30363d'
|
||||
}
|
||||
|
||||
function markerId(type: string): string {
|
||||
return `arrow-${uid}-` + type.replace(/[^a-z0-9]/gi, '_')
|
||||
}
|
||||
|
||||
function endpoint(end: string | Node): Node | undefined {
|
||||
return typeof end === 'object' ? end : nodes.find((n) => n.id === end)
|
||||
}
|
||||
function endpointId(end: string | Node): string {
|
||||
return typeof end === 'object' ? end.id : end
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true
|
||||
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
|
||||
loading = false
|
||||
if (!graph) return
|
||||
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]))
|
||||
const degree = new Map<string, number>()
|
||||
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
|
||||
for (const e of graph.edges) {
|
||||
const s = idBySlug.get(e.source) ?? e.source
|
||||
const t = idBySlug.get(e.target) ?? e.target
|
||||
degree.set(s, (degree.get(s) ?? 0) + 1)
|
||||
degree.set(t, (degree.get(t) ?? 0) + 1)
|
||||
}
|
||||
|
||||
nodes = graph.nodes.map((n) => {
|
||||
const prev = byId.get(n.id)
|
||||
return { ...n, x: prev?.x, y: prev?.y, degree: degree.get(n.id) ?? 0 }
|
||||
})
|
||||
links = graph.edges.map((e) => ({
|
||||
source: idBySlug.get(e.source) ?? e.source,
|
||||
target: idBySlug.get(e.target) ?? e.target,
|
||||
type: e.type
|
||||
}))
|
||||
|
||||
// Edge-type toggles default to everything present — node-type toggles
|
||||
// are owned by the parent (activeNodeTypes) and persist across reloads.
|
||||
activeRelTypes = new Set(links.map((l) => l.type))
|
||||
|
||||
sim?.stop()
|
||||
sim = forceSimulation(nodes)
|
||||
.force('link', forceLink<Node, Link>(links).id((n) => n.id).distance(70).strength(0.6))
|
||||
.force('charge', forceManyBody().strength(-240).distanceMax(400))
|
||||
.force('center', forceCenter(width / 2, height / 2))
|
||||
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 8))
|
||||
.force('x', forceX(width / 2).strength(0.04))
|
||||
.force('y', forceY(height / 2).strength(0.04))
|
||||
.velocityDecay(0.32)
|
||||
.alphaDecay(0.035)
|
||||
.on('tick', () => {
|
||||
nodes = [...nodes]
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load()
|
||||
const unsubscribe = subscribeEvents()
|
||||
return () => {
|
||||
unsubscribe()
|
||||
sim?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => sim?.stop())
|
||||
|
||||
$effect(() => {
|
||||
const ev = $liveEvents[0]
|
||||
if (!ev) return
|
||||
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
// Toolbar-driven reload/reset — mirrors the old onchange={load} behavior:
|
||||
// typing freely doesn't refetch, only a committed change (Enter/blur in the
|
||||
// parent's inputs, or the Reset button) bumps the token.
|
||||
let lastReloadToken = $state(0)
|
||||
$effect(() => {
|
||||
if (reloadToken !== lastReloadToken) {
|
||||
lastReloadToken = reloadToken
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
let lastResetToken = $state(0)
|
||||
$effect(() => {
|
||||
if (resetToken !== lastResetToken) {
|
||||
lastResetToken = resetToken
|
||||
view = { x: 0, y: 0, k: 1 }
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
function selectNode(node: Node) {
|
||||
onSelect(node.slug)
|
||||
}
|
||||
|
||||
function rerootTo(node: Node) {
|
||||
root = node.slug
|
||||
load()
|
||||
}
|
||||
|
||||
function nodeColor(node: Node): string {
|
||||
const h = graph?.health?.[node.id]
|
||||
return h ? healthColor[h] : '#58a6ff'
|
||||
}
|
||||
|
||||
function nodeRadius(node: Node): number {
|
||||
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
|
||||
}
|
||||
|
||||
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
|
||||
|
||||
// Publish status/legend info up to the parent toolbar.
|
||||
$effect(() => {
|
||||
info = {
|
||||
allRelTypes,
|
||||
relColors: relColorByType,
|
||||
visibleCount: visibleNodeIds.size,
|
||||
truncated: !!graph?.truncated,
|
||||
zoomPct: Math.round(view.k * 100)
|
||||
}
|
||||
})
|
||||
|
||||
const matchedIds = $derived.by(() => {
|
||||
if (!search.trim()) return null
|
||||
const q = search.trim().toLowerCase()
|
||||
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
|
||||
})
|
||||
|
||||
// Focus = the shared type multiselect (activeNodeTypes) says this type is
|
||||
// visible — same control the entity table filters its rows by.
|
||||
const focusNodeIds = $derived(new Set(nodes.filter((n) => activeNodeTypes.has(n.type)).map((n) => n.id)))
|
||||
|
||||
// Real infra relationships mostly cross type lines (a service sits on a
|
||||
// network, uses storage, runs on an lxc). Hard-hiding any edge whose
|
||||
// other end isn't in the active type set left focus nodes looking like
|
||||
// disconnected dots. Rooted views (the user is exploring out from one
|
||||
// entity) pull in 1-hop neighbors of any type, dimmed, so the edges — and
|
||||
// what they connect to — stay visible. Unscoped "browse everything" views
|
||||
// (no root) skip this: with dozens of focus nodes that touch nearly
|
||||
// everything, 1-hop expansion floods in most of the graph (measured: 417
|
||||
// of 479 total entities for an unrooted Fleet-typed view) — worse than
|
||||
// the isolated-dot problem it was meant to fix. There, same-type-only
|
||||
// edges stay.
|
||||
const neighborNodeIds = $derived.by(() => {
|
||||
const neighbors = new Set<string>()
|
||||
if (!root.trim()) return neighbors
|
||||
for (const l of links) {
|
||||
if (!activeRelTypes.has(l.type)) continue
|
||||
const s = endpointId(l.source)
|
||||
const t = endpointId(l.target)
|
||||
if (focusNodeIds.has(s) && !focusNodeIds.has(t)) neighbors.add(t)
|
||||
else if (focusNodeIds.has(t) && !focusNodeIds.has(s)) neighbors.add(s)
|
||||
}
|
||||
return neighbors
|
||||
})
|
||||
|
||||
const visibleNodeIds = $derived(new Set([...focusNodeIds, ...neighborNodeIds]))
|
||||
|
||||
const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null)
|
||||
|
||||
const adjacency = $derived.by(() => {
|
||||
const adj = new Map<string, Set<string>>()
|
||||
for (const l of links) {
|
||||
const s = endpointId(l.source)
|
||||
const t = endpointId(l.target)
|
||||
if (!adj.has(s)) adj.set(s, new Set())
|
||||
if (!adj.has(t)) adj.set(t, new Set())
|
||||
adj.get(s)!.add(t)
|
||||
adj.get(t)!.add(s)
|
||||
}
|
||||
return adj
|
||||
})
|
||||
|
||||
const focusIds = $derived.by(() => {
|
||||
const focus = hoveredId ?? selectedId
|
||||
if (!focus) return null
|
||||
const set = new Set<string>([focus])
|
||||
for (const n of adjacency.get(focus) ?? []) set.add(n)
|
||||
return set
|
||||
})
|
||||
|
||||
function nodeOpacity(node: Node): number {
|
||||
const base = focusNodeIds.has(node.id) ? 1 : 0.4
|
||||
if (matchedIds !== null) return matchedIds.has(node.id) ? base : 0.1
|
||||
if (focusIds !== null) return focusIds.has(node.id) ? 1 : Math.min(base, 0.15)
|
||||
return base
|
||||
}
|
||||
|
||||
function linkVisualState(link: Link): { opacity: number; emphasized: boolean } {
|
||||
const s = endpointId(link.source)
|
||||
const t = endpointId(link.target)
|
||||
const focus = hoveredId ?? selectedId
|
||||
if (focus && (s === focus || t === focus)) return { opacity: 0.95, emphasized: true }
|
||||
if (focusIds !== null || matchedIds !== null) return { opacity: 0.08, emphasized: false }
|
||||
return { opacity: 0.45, emphasized: false }
|
||||
}
|
||||
|
||||
// ─── pan / zoom / drag ───────────────────────────────────────────────
|
||||
|
||||
function toViewBox(clientX: number, clientY: number): { x: number; y: number } {
|
||||
const rect = svgEl!.getBoundingClientRect()
|
||||
return {
|
||||
x: ((clientX - rect.left) / rect.width) * width,
|
||||
y: ((clientY - rect.top) / rect.height) * height
|
||||
}
|
||||
}
|
||||
|
||||
function toWorld(clientX: number, clientY: number): { x: number; y: number } {
|
||||
const p = toViewBox(clientX, clientY)
|
||||
return { x: (p.x - view.x) / view.k, y: (p.y - view.y) / view.k }
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
const factor = e.deltaY < 0 ? 1.18 : 1 / 1.18
|
||||
const k = Math.min(6, Math.max(0.25, view.k * factor))
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
const wx = (p.x - view.x) / view.k
|
||||
const wy = (p.y - view.y) / view.k
|
||||
view = { k, x: p.x - wx * k, y: p.y - wy * k }
|
||||
}
|
||||
|
||||
let panState = $state<{ startX: number; startY: number; viewX: number; viewY: number; moved: boolean } | null>(null)
|
||||
let dragState: { node: Node; moved: boolean } | null = null
|
||||
|
||||
function onBackgroundPointerDown(e: PointerEvent) {
|
||||
if (dragState) return
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y, moved: false }
|
||||
}
|
||||
|
||||
function onNodePointerDown(e: PointerEvent, node: Node) {
|
||||
e.stopPropagation()
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
dragState = { node, moved: false }
|
||||
sim?.alphaTarget(0.25).restart()
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (dragState) {
|
||||
const w = toWorld(e.clientX, e.clientY)
|
||||
dragState.node.fx = w.x
|
||||
dragState.node.fy = w.y
|
||||
dragState.moved = true
|
||||
return
|
||||
}
|
||||
if (panState) {
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
const dx = p.x - panState.startX
|
||||
const dy = p.y - panState.startY
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) panState.moved = true
|
||||
view = { ...view, x: panState.viewX + dx, y: panState.viewY + dy }
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (dragState) {
|
||||
const { node, moved } = dragState
|
||||
node.fx = null
|
||||
node.fy = null
|
||||
sim?.alphaTarget(0)
|
||||
dragState = null
|
||||
if (!moved) selectNode(node)
|
||||
return
|
||||
}
|
||||
if (panState && !panState.moved) {
|
||||
// Plain click on empty background (not a drag-pan) — clear selection.
|
||||
onSelect(null)
|
||||
}
|
||||
panState = null
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading && !nodes.length}
|
||||
<Skeleton class="h-full min-h-0" />
|
||||
{:else}
|
||||
<div class="relative h-full min-h-0 overflow-hidden rounded-lg border">
|
||||
<svg
|
||||
bind:this={svgEl}
|
||||
viewBox="0 0 {width} {height}"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
class="h-full w-full touch-none {panState ? 'cursor-grabbing' : 'cursor-grab'}"
|
||||
role="application"
|
||||
aria-label="Entity graph"
|
||||
onwheel={onWheel}
|
||||
onpointerdown={onBackgroundPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
>
|
||||
<defs>
|
||||
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
|
||||
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
|
||||
</pattern>
|
||||
{#each allRelTypes as type}
|
||||
<marker id={markerId(type)} viewBox="0 -4 8 8" refX="8" refY="0" markerWidth="7" markerHeight="7" orient="auto">
|
||||
<path d="M0,-3.5L8,0L0,3.5" fill={relColor(type)} />
|
||||
</marker>
|
||||
{/each}
|
||||
</defs>
|
||||
<rect x="0" y="0" width={width} height={height} fill="url(#{dotGridId})" />
|
||||
<g transform="translate({view.x},{view.y}) scale({view.k})">
|
||||
<g>
|
||||
{#each links as link}
|
||||
{@const s = endpoint(link.source)}
|
||||
{@const t = endpoint(link.target)}
|
||||
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null && activeRelTypes.has(link.type) && visibleNodeIds.has(s.id) && visibleNodeIds.has(t.id)}
|
||||
{@const vs = linkVisualState(link)}
|
||||
{@const dx = t.x - s.x}
|
||||
{@const dy = t.y - s.y}
|
||||
{@const len = Math.max(Math.hypot(dx, dy), 1)}
|
||||
{@const curve = Math.min(len * 0.15, 40)}
|
||||
{@const cx = (s.x + t.x) / 2 - (dy / len) * curve}
|
||||
{@const cy = (s.y + t.y) / 2 + (dx / len) * curve}
|
||||
{@const cdx = t.x - cx}
|
||||
{@const cdy = t.y - cy}
|
||||
{@const clen = Math.max(Math.hypot(cdx, cdy), 1)}
|
||||
{@const tr = nodeRadius(t) + 3}
|
||||
{@const ex = t.x - (cdx / clen) * tr}
|
||||
{@const ey = t.y - (cdy / clen) * tr}
|
||||
{@const mx = 0.25 * s.x + 0.5 * cx + 0.25 * ex}
|
||||
{@const my = 0.25 * s.y + 0.5 * cy + 0.25 * ey}
|
||||
<path
|
||||
d="M {s.x},{s.y} Q {cx},{cy} {ex},{ey}"
|
||||
fill="none"
|
||||
stroke={relColor(link.type)}
|
||||
stroke-width={vs.emphasized ? 2 : 1.2}
|
||||
opacity={vs.opacity}
|
||||
marker-end="url(#{markerId(link.type)})"
|
||||
>
|
||||
<title>{link.type}</title>
|
||||
</path>
|
||||
{#if vs.emphasized && view.k >= 0.7}
|
||||
<text
|
||||
x={mx}
|
||||
y={my - 4}
|
||||
text-anchor="middle"
|
||||
font-size={10 / view.k}
|
||||
fill={relColor(link.type)}
|
||||
opacity="0.95"
|
||||
paint-order="stroke"
|
||||
stroke="var(--background)"
|
||||
stroke-width={3 / view.k}
|
||||
>
|
||||
{link.type}
|
||||
</text>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
<g>
|
||||
{#each nodes as node (node.id)}
|
||||
{#if node.x != null && node.y != null && visibleNodeIds.has(node.id)}
|
||||
{@const r = nodeRadius(node)}
|
||||
{@const op = nodeOpacity(node)}
|
||||
{@const isFocus = hoveredId === node.id || selectedId === node.id}
|
||||
{@const isMatch = matchedIds !== null && matchedIds.has(node.id)}
|
||||
<g
|
||||
transform="translate({node.x},{node.y})"
|
||||
opacity={op}
|
||||
class="cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onpointerdown={(e) => onNodePointerDown(e, node)}
|
||||
onpointerenter={() => (hoveredId = node.id)}
|
||||
onpointerleave={() => (hoveredId = null)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectNode(node)}
|
||||
ondblclick={() => rerootTo(node)}
|
||||
>
|
||||
{#if isFocus || isMatch}
|
||||
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
||||
{/if}
|
||||
<circle r={r} fill={nodeColor(node)} stroke={isFocus || isMatch ? 'var(--foreground)' : 'var(--background)'} stroke-width={isFocus || isMatch ? 2 : 1.25} />
|
||||
{#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null}
|
||||
<text
|
||||
y={r + 12}
|
||||
text-anchor="middle"
|
||||
font-size={isFocus ? 12 / view.k : 10 / Math.max(view.k, 1)}
|
||||
fill={isFocus ? 'var(--foreground)' : 'var(--muted-foreground)'}
|
||||
paint-order="stroke"
|
||||
stroke="var(--background)"
|
||||
stroke-width={3 / view.k}
|
||||
class="pointer-events-none select-none"
|
||||
>
|
||||
{node.slug}
|
||||
</text>
|
||||
{/if}
|
||||
</g>
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="pointer-events-none absolute bottom-2 left-2 rounded bg-background/80 px-2 py-1 text-[10px] text-muted-foreground">
|
||||
scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -51,7 +51,13 @@
|
||||
return { sorted: true, direction: sortDir }
|
||||
}
|
||||
|
||||
const healthRank: Record<string, number> = { down: 0, degraded: 1, stale: 2, unknown: 3, healthy: 4 }
|
||||
const healthRank: Record<string, number> = {
|
||||
down: 0,
|
||||
degraded: 1,
|
||||
stale: 2,
|
||||
unknown: 3,
|
||||
healthy: 4
|
||||
}
|
||||
|
||||
function sortValue(entity: Entity, key: SortKey): string | number {
|
||||
if (key === 'health') return entity.health ? (healthRank[entity.health] ?? -1) : -1
|
||||
@@ -131,7 +137,9 @@
|
||||
{:else}
|
||||
{#snippet row(entity: Entity, level: number, ancestors: Set<string>)}
|
||||
{@const ancestorsWithSelf = new Set(ancestors).add(entity.slug)}
|
||||
{@const children = (childrenByParent.get(entity.slug) ?? []).filter((c) => !ancestorsWithSelf.has(c.slug))}
|
||||
{@const children = (childrenByParent.get(entity.slug) ?? []).filter(
|
||||
(c) => !ancestorsWithSelf.has(c.slug)
|
||||
)}
|
||||
<Table.Row
|
||||
class="cursor-pointer {entity.slug === selectedSlug ? 'bg-muted' : ''}"
|
||||
role="row"
|
||||
@@ -139,7 +147,12 @@
|
||||
aria-expanded={children.length > 0 ? !collapsedNodes.has(entity.slug) : undefined}
|
||||
tabindex={0}
|
||||
onclick={() => onSelect(entity.slug)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(entity.slug) } }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onSelect(entity.slug)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Table.Cell class="font-mono text-xs">
|
||||
<span class="flex items-center gap-1" style="padding-left: {(level - 1) * 1.25}rem">
|
||||
@@ -149,7 +162,9 @@
|
||||
type="button"
|
||||
class="rounded text-muted-foreground hover:text-foreground"
|
||||
onclick={(e) => toggleNode(entity.slug, e)}
|
||||
aria-label={collapsedNodes.has(entity.slug) ? `Expand ${entity.slug}` : `Collapse ${entity.slug}`}
|
||||
aria-label={collapsedNodes.has(entity.slug)
|
||||
? `Expand ${entity.slug}`
|
||||
: `Collapse ${entity.slug}`}
|
||||
>
|
||||
{#if collapsedNodes.has(entity.slug)}
|
||||
<ChevronRightIcon class="size-3.5" />
|
||||
@@ -186,23 +201,48 @@
|
||||
<Table.Row>
|
||||
{@const ssSlug = getSortState('slug')}
|
||||
<Table.Head>
|
||||
<SortHeader label="Slug" sorted={ssSlug.sorted} direction={ssSlug.direction} onclick={() => sortBy('slug')} />
|
||||
<SortHeader
|
||||
label="Slug"
|
||||
sorted={ssSlug.sorted}
|
||||
direction={ssSlug.direction}
|
||||
onclick={() => sortBy('slug')}
|
||||
/>
|
||||
</Table.Head>
|
||||
{@const ssType = getSortState('type')}
|
||||
<Table.Head>
|
||||
<SortHeader label="Type" sorted={ssType.sorted} direction={ssType.direction} onclick={() => sortBy('type')} />
|
||||
<SortHeader
|
||||
label="Type"
|
||||
sorted={ssType.sorted}
|
||||
direction={ssType.direction}
|
||||
onclick={() => sortBy('type')}
|
||||
/>
|
||||
</Table.Head>
|
||||
{@const ssName = getSortState('name')}
|
||||
<Table.Head>
|
||||
<SortHeader label="Name" sorted={ssName.sorted} direction={ssName.direction} onclick={() => sortBy('name')} />
|
||||
<SortHeader
|
||||
label="Name"
|
||||
sorted={ssName.sorted}
|
||||
direction={ssName.direction}
|
||||
onclick={() => sortBy('name')}
|
||||
/>
|
||||
</Table.Head>
|
||||
{@const ssState = getSortState('state')}
|
||||
<Table.Head>
|
||||
<SortHeader label="State" sorted={ssState.sorted} direction={ssState.direction} onclick={() => sortBy('state')} />
|
||||
<SortHeader
|
||||
label="State"
|
||||
sorted={ssState.sorted}
|
||||
direction={ssState.direction}
|
||||
onclick={() => sortBy('state')}
|
||||
/>
|
||||
</Table.Head>
|
||||
{@const ssHealth = getSortState('health')}
|
||||
<Table.Head>
|
||||
<SortHeader label="Health" sorted={ssHealth.sorted} direction={ssHealth.direction} onclick={() => sortBy('health')} />
|
||||
<SortHeader
|
||||
label="Health"
|
||||
sorted={ssHealth.sorted}
|
||||
direction={ssHealth.direction}
|
||||
onclick={() => sortBy('health')}
|
||||
/>
|
||||
</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
@@ -8,14 +8,22 @@
|
||||
children
|
||||
}: {
|
||||
value?: string
|
||||
tabs: { value: string; label: string; count?: number; variant?: 'destructive' | 'default' | 'secondary' | 'outline' }[]
|
||||
tabs: {
|
||||
value: string
|
||||
label: string
|
||||
count?: number
|
||||
variant?: 'destructive' | 'default' | 'secondary' | 'outline'
|
||||
}[]
|
||||
class?: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children?: any
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<Tabs.Root bind:value class={['flex flex-1 flex-col overflow-hidden', className].filter(Boolean).join(' ')}>
|
||||
<Tabs.Root
|
||||
bind:value
|
||||
class={['flex flex-1 flex-col overflow-hidden', className].filter(Boolean).join(' ')}
|
||||
>
|
||||
<Tabs.List>
|
||||
{#each tabs as tab}
|
||||
<Tabs.Trigger value={tab.value}>
|
||||
|
||||
1288
web/src/lib/components/FleetMap.svelte
Normal file
1288
web/src/lib/components/FleetMap.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,266 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, type Simulation } from 'd3-force'
|
||||
import { fetchGraph, type Health } from '$lib/api'
|
||||
import { getTheme } from '$lib/stores/theme.svelte'
|
||||
|
||||
// Ambient, non-interactive knowledge-graph backdrop. Purely decorative: the
|
||||
// host places this behind the page with pointer-events:none, so it never
|
||||
// steals clicks. The "alive" feeling comes entirely from the camera (slow
|
||||
// autonomous drift + mouse parallax + per-node depth), NOT from a live force
|
||||
// sim — we warm the layout up once, freeze it, then just pan a static field.
|
||||
|
||||
interface SimNode {
|
||||
id: string
|
||||
slug: string
|
||||
degree: number
|
||||
z: number // depth in [0,1] for parallax
|
||||
x?: number
|
||||
y?: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
}
|
||||
interface SimLink {
|
||||
source: string | SimNode
|
||||
target: string | SimNode
|
||||
}
|
||||
|
||||
let host = $state<HTMLDivElement | null>(null)
|
||||
let canvas = $state<HTMLCanvasElement | null>(null)
|
||||
|
||||
let nodes: SimNode[] = []
|
||||
let links: SimLink[] = []
|
||||
let health: Record<string, Health> = {}
|
||||
|
||||
// World bounds the layout is centered in; camera pans within.
|
||||
const WORLD = 1400
|
||||
const MAX_NODES = 260
|
||||
|
||||
const healthColor: Record<Health, string> = {
|
||||
healthy: '#3fb950',
|
||||
degraded: '#d29922',
|
||||
down: '#f85149',
|
||||
unknown: '#8b949e'
|
||||
}
|
||||
|
||||
function nodeRadius(n: SimNode): number {
|
||||
return 3 + Math.min(Math.sqrt(n.degree) * 1.4, 7)
|
||||
}
|
||||
|
||||
async function loadGraph() {
|
||||
const graph = await fetchGraph({ depth: 3, includeStatus: true })
|
||||
if (!graph) return
|
||||
health = graph.health ?? {}
|
||||
|
||||
// degree by id, edges reference slugs
|
||||
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
|
||||
const degree = new Map<string, number>()
|
||||
for (const e of graph.edges) {
|
||||
const s = idBySlug.get(e.source) ?? e.source
|
||||
const t = idBySlug.get(e.target) ?? e.target
|
||||
degree.set(s, (degree.get(s) ?? 0) + 1)
|
||||
degree.set(t, (degree.get(t) ?? 0) + 1)
|
||||
}
|
||||
|
||||
let all: SimNode[] = graph.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
slug: n.slug,
|
||||
degree: degree.get(n.id) ?? 0,
|
||||
z: Math.random()
|
||||
}))
|
||||
// Cap to the most-connected nodes so large graphs stay cheap.
|
||||
if (all.length > MAX_NODES) {
|
||||
all = [...all].sort((a, b) => b.degree - a.degree).slice(0, MAX_NODES)
|
||||
}
|
||||
const keep = new Set(all.map((n) => n.id))
|
||||
nodes = all
|
||||
links = graph.edges
|
||||
.map((e) => ({ source: idBySlug.get(e.source) ?? e.source, target: idBySlug.get(e.target) ?? e.target }))
|
||||
.filter((l) => keep.has(l.source as string) && keep.has(l.target as string))
|
||||
|
||||
warmUpLayout()
|
||||
}
|
||||
|
||||
// Run the sim to a settled state without rendering each tick, then freeze.
|
||||
function warmUpLayout() {
|
||||
const sim: Simulation<SimNode, SimLink> = forceSimulation(nodes)
|
||||
.force('link', forceLink<SimNode, SimLink>(links).id((n) => n.id).distance(60).strength(0.5))
|
||||
.force('charge', forceManyBody().strength(-140).distanceMax(360))
|
||||
.force('center', forceCenter(0, 0))
|
||||
.force('collide', forceCollide<SimNode>((n) => nodeRadius(n) + 6))
|
||||
.stop()
|
||||
const ticks = Math.min(400, Math.max(120, nodes.length * 2))
|
||||
for (let i = 0; i < ticks; i++) sim.tick()
|
||||
sim.stop()
|
||||
}
|
||||
|
||||
// ─── camera + render loop ───────────────────────────────────────────────
|
||||
|
||||
let cam = { x: 0, y: 0 } // eased mouse-parallax offset
|
||||
let targetCam = { x: 0, y: 0 }
|
||||
let timer: ReturnType<typeof setTimeout> | 0 = 0
|
||||
let dpr = 1
|
||||
let w = 0
|
||||
let h = 0
|
||||
let dotCanvas: HTMLCanvasElement | null = null
|
||||
let lastDotDark: boolean | null = null
|
||||
|
||||
function drawDots(dark: boolean) {
|
||||
if (!dotCanvas) {
|
||||
dotCanvas = document.createElement('canvas')
|
||||
}
|
||||
dotCanvas.width = Math.round(w * dpr)
|
||||
dotCanvas.height = Math.round(h * dpr)
|
||||
const dctx = dotCanvas.getContext('2d')!
|
||||
dctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
dctx.clearRect(0, 0, w, h)
|
||||
dctx.fillStyle = dark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)'
|
||||
const spacing = 12
|
||||
for (let x = spacing; x < w; x += spacing) {
|
||||
for (let y = spacing; y < h; y += spacing) {
|
||||
dctx.beginPath()
|
||||
dctx.arc(x, y, 0.7, 0, Math.PI * 2)
|
||||
dctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!host) return
|
||||
const rect = host.getBoundingClientRect()
|
||||
const nx = (e.clientX - rect.left) / rect.width - 0.5 // -0.5..0.5
|
||||
const ny = (e.clientY - rect.top) / rect.height - 0.5
|
||||
targetCam = { x: -nx * 90, y: -ny * 90 } // small parallax nudge
|
||||
}
|
||||
|
||||
function resize() {
|
||||
if (!host || !canvas) return
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
w = host.clientWidth
|
||||
h = host.clientHeight
|
||||
canvas.width = Math.round(w * dpr)
|
||||
canvas.height = Math.round(h * dpr)
|
||||
dotCanvas = null // force redraw on next frame
|
||||
}
|
||||
|
||||
function colorForNode(n: SimNode): string {
|
||||
return healthColor[health[n.id] ?? 'unknown']
|
||||
}
|
||||
|
||||
// Driven by setTimeout rather than requestAnimationFrame: some embedding
|
||||
// contexts (iframed previews, backgrounded-but-visible panes) report
|
||||
// document.hidden = true and browsers fully suspend rAF callbacks there,
|
||||
// which would freeze this canvas forever. setTimeout keeps ticking
|
||||
// regardless, and ~30fps is plenty for a slow ambient drift.
|
||||
function draw(t: number) {
|
||||
timer = setTimeout(() => draw(performance.now()), 33)
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// ease parallax toward target
|
||||
cam.x += (targetCam.x - cam.x) * 0.05
|
||||
cam.y += (targetCam.y - cam.y) * 0.05
|
||||
|
||||
// autonomous drift (Lissajous pan + breathing zoom)
|
||||
const ts = t / 1000
|
||||
const driftX = Math.sin(ts * 0.05) * 70 + Math.sin(ts * 0.017) * 40
|
||||
const driftY = Math.cos(ts * 0.043) * 60 + Math.sin(ts * 0.023) * 30
|
||||
const zoom = 0.82 + Math.sin(ts * 0.03) * 0.03
|
||||
|
||||
const dark = getTheme() !== 'light'
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
if (lastDotDark !== dark) { dotCanvas = null; lastDotDark = dark }
|
||||
if (!dotCanvas) drawDots(dark)
|
||||
ctx.drawImage(dotCanvas!, 0, 0)
|
||||
|
||||
const cx = w / 2
|
||||
const cy = h / 2
|
||||
|
||||
// project a world point to screen, applying per-depth parallax
|
||||
function project(px: number, py: number, z: number) {
|
||||
const par = 0.5 + z // nearer nodes (higher z) move more
|
||||
const ox = (driftX + cam.x) * par
|
||||
const oy = (driftY + cam.y) * par
|
||||
return { x: cx + (px + ox) * zoom, y: cy + (py + oy) * zoom }
|
||||
}
|
||||
|
||||
// edges
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeStyle = dark ? 'rgba(140,175,230,0.28)' : 'rgba(60,90,140,0.22)'
|
||||
ctx.beginPath()
|
||||
for (const l of links) {
|
||||
const s = l.source as SimNode
|
||||
const tg = l.target as SimNode
|
||||
if (s.x == null || tg.x == null) continue
|
||||
const z = (s.z + tg.z) / 2
|
||||
const a = project(s.x, s.y!, z)
|
||||
const b = project(tg.x, tg.y!, z)
|
||||
const dx = b.x - a.x
|
||||
const dy = b.y - a.y
|
||||
const len = Math.max(Math.hypot(dx, dy), 1)
|
||||
const curve = Math.min(len * 0.15, 40)
|
||||
const mx = (a.x + b.x) / 2 - (dy / len) * curve
|
||||
const my = (a.y + b.y) / 2 + (dx / len) * curve
|
||||
ctx.moveTo(a.x, a.y)
|
||||
ctx.quadraticCurveTo(mx, my, b.x, b.y)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// nodes (glow via radial gradient, cheap enough at this count)
|
||||
for (const n of nodes) {
|
||||
if (n.x == null || n.y == null) continue
|
||||
const p = project(n.x, n.y, n.z)
|
||||
const r = nodeRadius(n) * zoom * (0.7 + n.z * 0.6)
|
||||
const col = colorForNode(n)
|
||||
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 3.2)
|
||||
glow.addColorStop(0, hexA(col, dark ? 0.45 : 0.32))
|
||||
glow.addColorStop(1, hexA(col, 0))
|
||||
ctx.fillStyle = glow
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, r * 3.2, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = hexA(col, dark ? 0.7 : 0.55)
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// legibility scrim: dim only the center band where the UI sits, taper to
|
||||
// ~nothing at the edges so the graph (and its connections) stay visible
|
||||
// in the margins instead of being crushed everywhere equally.
|
||||
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
|
||||
const base = dark ? '13,17,23' : '255,255,255'
|
||||
scrim.addColorStop(0, `rgba(${base},0.68)`)
|
||||
scrim.addColorStop(0.45, `rgba(${base},0.32)`)
|
||||
scrim.addColorStop(1, `rgba(${base},0.02)`)
|
||||
ctx.fillStyle = scrim
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
}
|
||||
|
||||
// "#rrggbb" + alpha -> rgba()
|
||||
function hexA(hex: string, a: number): string {
|
||||
const n = parseInt(hex.slice(1), 16)
|
||||
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadGraph()
|
||||
resize()
|
||||
const ro = new ResizeObserver(resize)
|
||||
if (host) ro.observe(host)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
timer = setTimeout(() => draw(performance.now()), 33)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
ro.disconnect()
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div bind:this={host} class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<canvas bind:this={canvas} class="h-full w-full"></canvas>
|
||||
</div>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
|
||||
let {
|
||||
label,
|
||||
options,
|
||||
selected = $bindable(),
|
||||
colorFor
|
||||
}: {
|
||||
label: string
|
||||
options: string[]
|
||||
selected: Set<string>
|
||||
colorFor?: (option: string) => string
|
||||
} = $props()
|
||||
|
||||
function toggle(opt: string) {
|
||||
const next = new Set(selected)
|
||||
if (next.has(opt)) next.delete(opt)
|
||||
else next.add(opt)
|
||||
selected = next
|
||||
}
|
||||
|
||||
const allSelected = $derived(options.length > 0 && options.every((o) => selected.has(o)))
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" size="sm" class="h-8 gap-1.5">
|
||||
{label}
|
||||
<span class="text-muted-foreground">{selected.size}/{options.length}</span>
|
||||
<ChevronDownIcon class="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content class="max-h-80 w-56 overflow-y-auto" align="start">
|
||||
<DropdownMenu.Item
|
||||
closeOnSelect={false}
|
||||
onSelect={() => { selected = allSelected ? new Set() : new Set(options) }}
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{allSelected ? 'Deselect all' : 'Select all'}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{#each options as opt}
|
||||
<DropdownMenu.CheckboxItem
|
||||
closeOnSelect={false}
|
||||
checked={selected.has(opt)}
|
||||
onCheckedChange={() => toggle(opt)}
|
||||
class="text-xs"
|
||||
>
|
||||
{#if colorFor}
|
||||
<span class="size-2 shrink-0 rounded-full" style="background: {colorFor(opt)}"></span>
|
||||
{/if}
|
||||
{opt}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{/each}
|
||||
{#if options.length === 0}
|
||||
<p class="px-2 py-1.5 text-xs text-muted-foreground">No types loaded yet.</p>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
@@ -5,7 +5,8 @@
|
||||
import CircleHelpIcon from '@lucide/svelte/icons/circle-help'
|
||||
|
||||
// Prop-driven (not store-imported) — see SessionGraph.svelte for why.
|
||||
let { sessionId, question }: { sessionId: string | null; question: SessionQuestion | null } = $props()
|
||||
let { sessionId, question }: { sessionId: string | null; question: SessionQuestion | null } =
|
||||
$props()
|
||||
|
||||
let freeText = $state('')
|
||||
let submitting = $state(false)
|
||||
@@ -48,7 +49,13 @@
|
||||
{#if q.context.options?.length}
|
||||
<div class="ml-6 flex flex-wrap gap-1.5">
|
||||
{#each q.context.options as opt}
|
||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" disabled={submitting} onclick={() => submit(opt)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 px-2.5 text-xs"
|
||||
disabled={submitting}
|
||||
onclick={() => submit(opt)}
|
||||
>
|
||||
{opt}
|
||||
</Button>
|
||||
{/each}
|
||||
@@ -69,7 +76,12 @@
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" class="h-7 px-2.5 text-xs" disabled={!freeText.trim() || submitting} onclick={() => submit(freeText)}>
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-7 px-2.5 text-xs"
|
||||
disabled={!freeText.trim() || submitting}
|
||||
onclick={() => submit(freeText)}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,15 @@
|
||||
// these can be open (and independently live) at once.
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { chatFor, loadSessionChat, sendSessionMessage, cancelSessionStream, stopSessionPolling, dismissError, chatErrors } from '$lib/stores/chat'
|
||||
import {
|
||||
chatFor,
|
||||
loadSessionChat,
|
||||
sendSessionMessage,
|
||||
cancelSessionStream,
|
||||
stopSessionPolling,
|
||||
dismissError,
|
||||
chatErrors
|
||||
} from '$lib/stores/chat'
|
||||
import { activityLogFor } from '$lib/stores/activity'
|
||||
import { workspaceFor, startSessionWorkspace } from '$lib/stores/workspace'
|
||||
import ChatThread from '$lib/components/ChatThread.svelte'
|
||||
@@ -77,7 +85,9 @@
|
||||
|
||||
<div class="flex h-full min-h-0">
|
||||
{#if loading}
|
||||
<div class="flex flex-1 items-center justify-center text-xs text-muted-foreground">Loading…</div>
|
||||
<div class="flex flex-1 items-center justify-center text-xs text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
{:else if $chatNotFound}
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-1 p-6 text-center">
|
||||
<p class="text-sm text-muted-foreground">Task not found.</p>
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
// Prop-driven (not store-imported) so this can render either the main
|
||||
// page's global "current session" data or a floating task window's own
|
||||
// per-session data — see TaskContextPanel.svelte, which supplies both.
|
||||
let { messages, touched, healthDiffs }: { messages: ChatMessage[]; touched: TouchedEntity[]; healthDiffs: HealthDiff[] } = $props()
|
||||
let {
|
||||
messages,
|
||||
touched,
|
||||
healthDiffs
|
||||
}: { messages: ChatMessage[]; touched: TouchedEntity[]; healthDiffs: HealthDiff[] } = $props()
|
||||
|
||||
// SVG ids are document-global, not scoped to this <svg> — several task
|
||||
// windows can each have their own Scope graph open at once, and without a
|
||||
@@ -138,7 +142,12 @@
|
||||
const curSlugs = new Set(current.map((n) => n.slug))
|
||||
|
||||
let changed = desiredSlugs.size !== curSlugs.size
|
||||
if (!changed) for (const s of desiredSlugs) if (!curSlugs.has(s)) { changed = true; break }
|
||||
if (!changed)
|
||||
for (const s of desiredSlugs)
|
||||
if (!curSlugs.has(s)) {
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
if (!changed) return
|
||||
|
||||
const bySlug = new Map(current.map((n) => [n.slug, n]))
|
||||
@@ -178,10 +187,19 @@
|
||||
return
|
||||
}
|
||||
sim = forceSimulation(nodes)
|
||||
.force('link', forceLink<Node, Edge>(links).id((n) => n.slug).distance(48).strength(0.5))
|
||||
.force(
|
||||
'link',
|
||||
forceLink<Node, Edge>(links)
|
||||
.id((n) => n.slug)
|
||||
.distance(48)
|
||||
.strength(0.5)
|
||||
)
|
||||
.force('charge', forceManyBody().strength(-150).distanceMax(240))
|
||||
.force('center', forceCenter(cw / 2, ch / 2))
|
||||
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 6))
|
||||
.force(
|
||||
'collide',
|
||||
forceCollide<Node>((n) => nodeRadius(n) + 6)
|
||||
)
|
||||
.force('x', forceX(cw / 2).strength(0.06))
|
||||
.force('y', forceY(ch / 2).strength(0.06))
|
||||
.velocityDecay(0.34)
|
||||
@@ -232,7 +250,9 @@
|
||||
unknown: 'var(--muted-foreground)'
|
||||
}
|
||||
function nodeColor(n: Node): string {
|
||||
return n.health ? healthColor[n.health] ?? 'var(--muted-foreground)' : 'var(--muted-foreground)'
|
||||
return n.health
|
||||
? (healthColor[n.health] ?? 'var(--muted-foreground)')
|
||||
: 'var(--muted-foreground)'
|
||||
}
|
||||
function nodeRadius(n: Node): number {
|
||||
return 6 + Math.min(Math.sqrt(n.degree) * 1.5, 6)
|
||||
@@ -306,10 +326,17 @@
|
||||
const selectedRelations = $derived(
|
||||
selected
|
||||
? links
|
||||
.filter((l) => endpointSlug(l.source) === selected!.slug || endpointSlug(l.target) === selected!.slug)
|
||||
.filter(
|
||||
(l) =>
|
||||
endpointSlug(l.source) === selected!.slug || endpointSlug(l.target) === selected!.slug
|
||||
)
|
||||
.map((l) => {
|
||||
const outgoing = endpointSlug(l.source) === selected!.slug
|
||||
return { dir: outgoing ? '→' : '←', type: l.type, other: outgoing ? endpointSlug(l.target) : endpointSlug(l.source) }
|
||||
return {
|
||||
dir: outgoing ? '→' : '←',
|
||||
type: l.type,
|
||||
other: outgoing ? endpointSlug(l.target) : endpointSlug(l.source)
|
||||
}
|
||||
})
|
||||
: []
|
||||
)
|
||||
@@ -317,7 +344,9 @@
|
||||
|
||||
<aside class="flex h-full min-h-0 flex-col bg-card/40">
|
||||
{#if nowTouching}
|
||||
<div class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary">
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary"
|
||||
>
|
||||
<span class="size-1.5 animate-pulse rounded-full bg-primary"></span>
|
||||
Now touching <code class="font-mono">{nowTouching.slug}</code>
|
||||
</div>
|
||||
@@ -325,22 +354,85 @@
|
||||
|
||||
<div bind:this={container} class="relative min-h-0 flex-1 overflow-hidden">
|
||||
{#if nodes.length === 0}
|
||||
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center"
|
||||
>
|
||||
<svg viewBox="0 0 120 120" class="size-24 text-muted-foreground/40" fill="none">
|
||||
<circle cx="60" cy="60" r="6" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.4;1;0.4" dur="2.4s" repeatCount="indefinite" />
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.4;1;0.4"
|
||||
dur="2.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<g stroke="currentColor" stroke-width="1" opacity="0.5">
|
||||
<line x1="60" y1="60" x2="26" y2="34"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3s" repeatCount="indefinite" /></line>
|
||||
<line x1="60" y1="60" x2="96" y2="40"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3.4s" repeatCount="indefinite" /></line>
|
||||
<line x1="60" y1="60" x2="34" y2="92"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="2.8s" repeatCount="indefinite" /></line>
|
||||
<line x1="60" y1="60" x2="92" y2="90"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3.1s" repeatCount="indefinite" /></line>
|
||||
<line x1="60" y1="60" x2="26" y2="34"
|
||||
><animate
|
||||
attributeName="opacity"
|
||||
values="0.1;0.5;0.1"
|
||||
dur="3s"
|
||||
repeatCount="indefinite"
|
||||
/></line
|
||||
>
|
||||
<line x1="60" y1="60" x2="96" y2="40"
|
||||
><animate
|
||||
attributeName="opacity"
|
||||
values="0.1;0.5;0.1"
|
||||
dur="3.4s"
|
||||
repeatCount="indefinite"
|
||||
/></line
|
||||
>
|
||||
<line x1="60" y1="60" x2="34" y2="92"
|
||||
><animate
|
||||
attributeName="opacity"
|
||||
values="0.1;0.5;0.1"
|
||||
dur="2.8s"
|
||||
repeatCount="indefinite"
|
||||
/></line
|
||||
>
|
||||
<line x1="60" y1="60" x2="92" y2="90"
|
||||
><animate
|
||||
attributeName="opacity"
|
||||
values="0.1;0.5;0.1"
|
||||
dur="3.1s"
|
||||
repeatCount="indefinite"
|
||||
/></line
|
||||
>
|
||||
</g>
|
||||
<g fill="currentColor">
|
||||
<circle cx="26" cy="34" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3s" repeatCount="indefinite" /></circle>
|
||||
<circle cx="96" cy="40" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3.4s" repeatCount="indefinite" /></circle>
|
||||
<circle cx="34" cy="92" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="2.8s" repeatCount="indefinite" /></circle>
|
||||
<circle cx="92" cy="90" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3.1s" repeatCount="indefinite" /></circle>
|
||||
<circle cx="26" cy="34" r="3.5"
|
||||
><animate
|
||||
attributeName="opacity"
|
||||
values="0.2;0.7;0.2"
|
||||
dur="3s"
|
||||
repeatCount="indefinite"
|
||||
/></circle
|
||||
>
|
||||
<circle cx="96" cy="40" r="3.5"
|
||||
><animate
|
||||
attributeName="opacity"
|
||||
values="0.2;0.7;0.2"
|
||||
dur="3.4s"
|
||||
repeatCount="indefinite"
|
||||
/></circle
|
||||
>
|
||||
<circle cx="34" cy="92" r="3.5"
|
||||
><animate
|
||||
attributeName="opacity"
|
||||
values="0.2;0.7;0.2"
|
||||
dur="2.8s"
|
||||
repeatCount="indefinite"
|
||||
/></circle
|
||||
>
|
||||
<circle cx="92" cy="90" r="3.5"
|
||||
><animate
|
||||
attributeName="opacity"
|
||||
values="0.2;0.7;0.2"
|
||||
dur="3.1s"
|
||||
repeatCount="indefinite"
|
||||
/></circle
|
||||
>
|
||||
</g>
|
||||
</svg>
|
||||
<p class="max-w-[16rem] text-xs leading-relaxed text-muted-foreground">
|
||||
@@ -394,7 +486,8 @@
|
||||
{#if node.x != null && node.y != null}
|
||||
{@const r = nodeRadius(node)}
|
||||
{@const isSel = selected?.slug === node.slug}
|
||||
{@const dim = selected && !isSel && !selectedRelations.some((rel) => rel.other === node.slug)}
|
||||
{@const dim =
|
||||
selected && !isSel && !selectedRelations.some((rel) => rel.other === node.slug)}
|
||||
{@const isTouched = node.slug in touchedBySlug}
|
||||
{@const diff = diffBySlug[node.slug]}
|
||||
<g
|
||||
@@ -410,12 +503,33 @@
|
||||
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
||||
{/if}
|
||||
{#if isTouched}
|
||||
<circle r={r + 4} fill="none" stroke="var(--primary)" stroke-width="1.5" opacity="0.8">
|
||||
<animate attributeName="r" values="{r + 3};{r + 8};{r + 3}" dur="1.6s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.8;0.1;0.8" dur="1.6s" repeatCount="indefinite" />
|
||||
<circle
|
||||
r={r + 4}
|
||||
fill="none"
|
||||
stroke="var(--primary)"
|
||||
stroke-width="1.5"
|
||||
opacity="0.8"
|
||||
>
|
||||
<animate
|
||||
attributeName="r"
|
||||
values="{r + 3};{r + 8};{r + 3}"
|
||||
dur="1.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.8;0.1;0.8"
|
||||
dur="1.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
{/if}
|
||||
<circle r={r} fill={nodeColor(node)} stroke={isSel ? 'var(--foreground)' : 'var(--background)'} stroke-width={isSel ? 2 : 1.5} />
|
||||
<circle
|
||||
{r}
|
||||
fill={nodeColor(node)}
|
||||
stroke={isSel ? 'var(--foreground)' : 'var(--background)'}
|
||||
stroke-width={isSel ? 2 : 1.5}
|
||||
/>
|
||||
<text
|
||||
y={r + 10}
|
||||
text-anchor="middle"
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
<svg viewBox="0 0 24 24" class={className} fill="none" aria-hidden="true">
|
||||
{#each Array.from({ length: TICKS }) as _, i (i)}
|
||||
<rect
|
||||
x="11" y="1.5" width="2" height="6" rx="1"
|
||||
x="11"
|
||||
y="1.5"
|
||||
width="2"
|
||||
height="6"
|
||||
rx="1"
|
||||
fill="currentColor"
|
||||
opacity="0.15"
|
||||
transform="rotate({i * (360 / TICKS)} 12 12)"
|
||||
|
||||
@@ -13,15 +13,18 @@
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
const variantMap: Record<StatusKind, Record<string, 'default' | 'secondary' | 'destructive' | 'outline'>> = {
|
||||
const variantMap: Record<
|
||||
StatusKind,
|
||||
Record<string, 'default' | 'secondary' | 'destructive' | 'outline'>
|
||||
> = {
|
||||
risk: {
|
||||
destructive: 'destructive',
|
||||
config_mutation: 'secondary',
|
||||
config_mutation: 'secondary'
|
||||
},
|
||||
severity: {
|
||||
critical: 'destructive',
|
||||
warning: 'secondary',
|
||||
info: 'default',
|
||||
info: 'default'
|
||||
},
|
||||
execution: {
|
||||
failed: 'destructive',
|
||||
@@ -30,13 +33,13 @@
|
||||
cancelled: 'destructive',
|
||||
completed: 'default',
|
||||
running: 'secondary',
|
||||
approved: 'secondary',
|
||||
approved: 'secondary'
|
||||
},
|
||||
type: {
|
||||
runbook: 'secondary',
|
||||
investigation: 'default',
|
||||
investigation: 'default'
|
||||
},
|
||||
default: {},
|
||||
default: {}
|
||||
}
|
||||
|
||||
function variant(): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { startWorkspace, planSteps, currentTask, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace'
|
||||
import {
|
||||
startWorkspace,
|
||||
planSteps,
|
||||
currentTask,
|
||||
touched,
|
||||
healthDiffs,
|
||||
workspaceFor,
|
||||
taskFor
|
||||
} from '$lib/stores/workspace'
|
||||
import { streaming, messages, chatFor } from '$lib/stores/chat'
|
||||
import { activityLog, activityLogFor } from '$lib/stores/activity'
|
||||
import SessionGraph from './SessionGraph.svelte'
|
||||
@@ -43,12 +51,12 @@
|
||||
// last size so reopening restores it.
|
||||
const COLLAPSED_SIZE = 6
|
||||
const OPEN_MIN_SIZE = 12
|
||||
let sizes = $state<(number | undefined)[]>([undefined, undefined])
|
||||
let sizes = $state<(number | undefined)[]>([30, 70])
|
||||
// Reopening must restore a concrete number, never `undefined` — the pane
|
||||
// only re-triggers the library's resize/equalize pass when `size` changes
|
||||
// to a different *number*, so setting it back to `undefined` silently
|
||||
// no-ops and leaves the section stuck at its collapsed height.
|
||||
let savedSizes: number[] = [34, 66]
|
||||
let savedSizes: number[] = [30, 70]
|
||||
|
||||
function toggleSection(i: number, isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
@@ -70,7 +78,12 @@
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
|
||||
<!-- Scope -->
|
||||
<Pane bind:size={sizes[0]} minSize={scopeOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={scopeOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
|
||||
<Pane
|
||||
bind:size={sizes[0]}
|
||||
minSize={scopeOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE}
|
||||
maxSize={scopeOpen ? 100 : COLLAPSED_SIZE}
|
||||
class="flex flex-col"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
@@ -79,21 +92,36 @@
|
||||
scopeOpen = !scopeOpen
|
||||
}}
|
||||
>
|
||||
{#if scopeOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
{#if scopeOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
|
||||
class="size-3"
|
||||
/>{/if}
|
||||
<span>Scope</span>
|
||||
{#if !scopeOpen}
|
||||
<span class="ml-auto font-normal normal-case">{$touchedStore.length ? `${$touchedStore.length} entit${$touchedStore.length === 1 ? 'y' : 'ies'}` : 'Graph'}</span>
|
||||
<span class="ml-auto font-normal normal-case"
|
||||
>{$touchedStore.length
|
||||
? `${$touchedStore.length} entit${$touchedStore.length === 1 ? 'y' : 'ies'}`
|
||||
: 'Graph'}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{#if scopeOpen}
|
||||
<div class="min-h-0 flex-1">
|
||||
<SessionGraph messages={$messagesStore} touched={$touchedStore} healthDiffs={$healthDiffsStore} />
|
||||
<SessionGraph
|
||||
messages={$messagesStore}
|
||||
touched={$touchedStore}
|
||||
healthDiffs={$healthDiffsStore}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Pane>
|
||||
|
||||
<!-- Activity (merged plan + event log) -->
|
||||
<Pane bind:size={sizes[1]} minSize={activityOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={activityOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
|
||||
<Pane
|
||||
bind:size={sizes[1]}
|
||||
minSize={activityOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE}
|
||||
maxSize={activityOpen ? 100 : COLLAPSED_SIZE}
|
||||
class="flex flex-col"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
@@ -102,25 +130,39 @@
|
||||
activityOpen = !activityOpen
|
||||
}}
|
||||
>
|
||||
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
|
||||
class="size-3"
|
||||
/>{/if}
|
||||
<span>Activity</span>
|
||||
{#if $streamingStore && activityRunning > 0}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
{/if}
|
||||
{#if planTotal > 0}
|
||||
<span class="font-normal normal-case tabular-nums {planDone === planTotal ? 'text-muted-foreground' : 'text-primary'}">{planDone}/{planTotal}</span>
|
||||
<span
|
||||
class="font-normal normal-case tabular-nums {planDone === planTotal
|
||||
? 'text-muted-foreground'
|
||||
: 'text-primary'}">{planDone}/{planTotal}</span
|
||||
>
|
||||
{/if}
|
||||
{#if !activityOpen && planTotal === 0}
|
||||
{#if $taskStore?.goal}
|
||||
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$taskStore.goal}</span>
|
||||
<span class="ml-auto max-w-[120px] truncate font-normal normal-case"
|
||||
>{$taskStore.goal}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="ml-auto font-normal normal-case text-muted-foreground">No activity yet</span>
|
||||
<span class="ml-auto font-normal normal-case text-muted-foreground"
|
||||
>No activity yet</span
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if activityOpen}
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<UnifiedTimeline entries={$activityLogStore} planSteps={$planStepsStore} streaming={$streamingStore} />
|
||||
<UnifiedTimeline
|
||||
entries={$activityLogStore}
|
||||
planSteps={$planStepsStore}
|
||||
streaming={$streamingStore}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Pane>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronRight, Loader2, Wrench, X } from '@lucide/svelte'
|
||||
// One tool call inside AgentTrace's expanded list. Renders as a borderless
|
||||
// row (the trace supplies the container/border) whose own click reveals the
|
||||
// raw args/result — so the trace stays a readable thinking log by default
|
||||
// and the JSON is one more click away, not stacked inline.
|
||||
import { Check, ChevronRight, Loader2, X } from '@lucide/svelte'
|
||||
import type { ToolCallResult } from '$lib/types'
|
||||
import { toolActivityLabel } from '$lib/stores/activity'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
let expanded = $state(false)
|
||||
@@ -11,11 +16,7 @@
|
||||
return 'done'
|
||||
})
|
||||
|
||||
const statusColor = $derived.by(() => {
|
||||
if (status === 'running') return 'text-primary'
|
||||
if (status === 'error') return 'text-destructive'
|
||||
return 'text-primary'
|
||||
})
|
||||
const label = $derived(toolActivityLabel(tool))
|
||||
|
||||
const argsSummary = $derived.by(() => {
|
||||
if (!tool.args) return ''
|
||||
@@ -25,61 +26,87 @@
|
||||
const val = typeof first[1] === 'string' ? first[1] : JSON.stringify(first[1])
|
||||
return `${first[0]}: ${val.length > 60 ? val.slice(0, 60) + '…' : val}`
|
||||
})
|
||||
|
||||
const hasDetail = $derived(
|
||||
!!tool.args || (tool.result !== undefined && tool.result !== null) || !!tool.error
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="tool-card rounded-lg border border-border/60 bg-card/40 overflow-hidden transition-all">
|
||||
<div class="tool-row">
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-muted/40 transition-colors"
|
||||
class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/40 disabled:cursor-default"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
aria-expanded={expanded}
|
||||
disabled={!hasDetail}
|
||||
>
|
||||
<ChevronRight class="size-3 shrink-0 text-muted-foreground transition-transform {expanded ? 'rotate-90' : ''}" />
|
||||
<Wrench class="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="font-mono text-xs font-medium text-foreground/80">{tool.name}</span>
|
||||
{#if argsSummary}
|
||||
<span class="ml-1 truncate text-[11px] text-muted-foreground/70">{argsSummary}</span>
|
||||
{/if}
|
||||
<span class="ml-auto shrink-0 {statusColor}">
|
||||
<span class="mt-px shrink-0 {status === 'error' ? 'text-destructive' : 'text-primary'}">
|
||||
{#if status === 'running'}
|
||||
<Loader2 class="size-3.5 animate-spin" />
|
||||
<Loader2 class="size-3 animate-spin" />
|
||||
{:else if status === 'error'}
|
||||
<X class="size-3.5" />
|
||||
<X class="size-3" />
|
||||
{:else}
|
||||
<Check class="size-3.5" />
|
||||
<Check class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-xs text-foreground/90">{label}</span>
|
||||
{#if argsSummary}
|
||||
<span class="block truncate font-mono text-[10px] text-muted-foreground/60"
|
||||
>{argsSummary}</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="shrink-0 font-mono text-[10px] text-muted-foreground/50">{tool.name}</span>
|
||||
{#if hasDetail}
|
||||
<ChevronRight
|
||||
class="mt-px size-3 shrink-0 text-muted-foreground/50 transition-transform {expanded
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="border-t border-border/40 px-3 py-2 space-y-2">
|
||||
<div class="space-y-2 px-2 pb-2 pl-7">
|
||||
{#if tool.args}
|
||||
<div>
|
||||
<div class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">Args</div>
|
||||
<pre class="tool-pre rounded-md bg-muted/60 p-2 text-[11px] overflow-x-auto max-h-48">{JSON.stringify(tool.args, null, 2)}</pre>
|
||||
<div
|
||||
class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Args
|
||||
</div>
|
||||
<pre
|
||||
class="max-h-48 overflow-x-auto rounded-md bg-muted/60 p-2 text-[11px]">{JSON.stringify(
|
||||
tool.args,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if tool.result !== undefined && tool.result !== null}
|
||||
<div>
|
||||
<div class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">Result</div>
|
||||
<pre class="tool-pre rounded-md bg-muted/60 p-2 text-[11px] overflow-x-auto max-h-48">{JSON.stringify(tool.result, null, 2)}</pre>
|
||||
<div
|
||||
class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Result
|
||||
</div>
|
||||
<pre
|
||||
class="max-h-48 overflow-x-auto rounded-md bg-muted/60 p-2 text-[11px]">{JSON.stringify(
|
||||
tool.result,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if tool.error}
|
||||
<div>
|
||||
<div class="text-[10px] font-semibold uppercase tracking-wider text-destructive mb-1">Error</div>
|
||||
<pre class="tool-pre rounded-md bg-destructive/5 border border-destructive/20 p-2 text-[11px] text-destructive overflow-x-auto max-h-48">{tool.error}</pre>
|
||||
<div class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-destructive">
|
||||
Error
|
||||
</div>
|
||||
<pre
|
||||
class="max-h-48 overflow-x-auto rounded-md border border-destructive/20 bg-destructive/5 p-2 text-[11px] text-destructive">{tool.error}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tool-card {
|
||||
animation: tool-in 0.2s ease-out;
|
||||
}
|
||||
@keyframes tool-in {
|
||||
from { opacity: 0; transform: translateY(-2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
import FlagIcon from '@lucide/svelte/icons/flag'
|
||||
|
||||
// Merged plan + activity timeline, designed for the narrow rail:
|
||||
// - ordered newest-first: what the agent is doing right now is at the top,
|
||||
// history flows downward, and the goal sits at the bottom where the task
|
||||
// began (see the sort in `items`)
|
||||
// - one continuous vertical "backbone"; every item owns a segment of it,
|
||||
// colored by state (done = filled primary, running = faint primary,
|
||||
// pending/future = muted) so the line visibly fills in as work completes
|
||||
@@ -25,8 +28,13 @@
|
||||
// markers on the same backbone
|
||||
// - the running step auto-expands and the view auto-scrolls to keep the
|
||||
// current step visible while the agent works (follow mode disengages if
|
||||
// the operator scrolls up, re-engages when streaming starts again)
|
||||
let { entries, planSteps: steps, streaming = false }: {
|
||||
// the operator scrolls down into history, re-engages when streaming
|
||||
// starts again)
|
||||
let {
|
||||
entries,
|
||||
planSteps: steps,
|
||||
streaming = false
|
||||
}: {
|
||||
entries: ActivityEntry[]
|
||||
planSteps: PlanStep[]
|
||||
streaming?: boolean
|
||||
@@ -63,18 +71,24 @@
|
||||
for (const s of steps) {
|
||||
if (s.status === 'pending' && !entries.some((e) => e.stepSeq === s.seq)) {
|
||||
// Pending steps with no activity yet still show on the timeline so
|
||||
// the operator sees what's coming — but only if a plan exists.
|
||||
// the operator sees what's coming — but only if a plan exists. ts 0
|
||||
// parks them at the tail of the newest-first sort below (see there).
|
||||
if (steps.length > 0) {
|
||||
out.push({ kind: 'step', step: s, tools: [], ts: Number.MAX_SAFE_INTEGER - s.seq })
|
||||
out.push({ kind: 'step', step: s, tools: [], ts: 0 })
|
||||
}
|
||||
continue
|
||||
}
|
||||
const tools = entries.filter(
|
||||
(e) => e.stepSeq === s.seq && (e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error')
|
||||
(e) =>
|
||||
e.stepSeq === s.seq &&
|
||||
(e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error')
|
||||
)
|
||||
const stepEntry = entries.find((e) => e.id === s.id)
|
||||
// Timed from the step's own entry, else its earliest tool — so a step
|
||||
// is placed by when it started, not by its latest activity.
|
||||
const ts = stepEntry?.timestamp ?? tools[0]?.timestamp ?? Date.now()
|
||||
out.push({ kind: 'step', step: s, tools, ts })
|
||||
// Tools inside a step run newest-first too, matching the outer order.
|
||||
out.push({ kind: 'step', step: s, tools: [...tools].reverse(), ts })
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
@@ -84,7 +98,15 @@
|
||||
out.push({ kind: 'entry', entry: e, ts: e.timestamp })
|
||||
}
|
||||
|
||||
out.sort((a, b) => a.ts - b.ts)
|
||||
// Newest first: whatever the agent is doing right now sits at the top of
|
||||
// the rail, with history flowing downward. The two ts-0 groups fall to
|
||||
// the bottom for free, which is where both belong in this order: the goal
|
||||
// (timestamp 0 — where the task started) and not-yet-run plan steps.
|
||||
// Sorting the latter by their future position would put them *above* the
|
||||
// running step and push it off the top, which is exactly what this
|
||||
// ordering exists to prevent. Array.sort is stable, so each group keeps
|
||||
// its insertion order (plan steps in seq order).
|
||||
out.sort((a, b) => b.ts - a.ts)
|
||||
return out
|
||||
})
|
||||
|
||||
@@ -100,9 +122,11 @@
|
||||
let container = $state<HTMLDivElement | null>(null)
|
||||
let follow = $state(true)
|
||||
|
||||
// Newest-first, so "following the agent" means being parked at the top —
|
||||
// the mirror of the bottom-anchored follow this had when it ran oldest-first.
|
||||
function onScroll() {
|
||||
if (!container) return
|
||||
follow = container.scrollHeight - container.scrollTop - container.clientHeight < 80
|
||||
follow = container.scrollTop < 80
|
||||
}
|
||||
|
||||
// A new turn re-engages follow mode even if the operator had scrolled up.
|
||||
@@ -116,7 +140,8 @@
|
||||
// entries land while following (instant, to avoid scroll-queue jank).
|
||||
$effect(() => {
|
||||
if (!currentId || !follow || !container) return
|
||||
container.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
|
||||
container
|
||||
.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
})
|
||||
let lastEntryCount = 0
|
||||
@@ -129,7 +154,7 @@
|
||||
? container.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
|
||||
: null
|
||||
if (target) target.scrollIntoView({ behavior: 'auto', block: 'nearest' })
|
||||
else container.scrollTop = container.scrollHeight
|
||||
else container.scrollTop = 0
|
||||
})
|
||||
|
||||
// ── Presentation helpers ──────────────────────────────────────────────────
|
||||
@@ -139,7 +164,12 @@
|
||||
// height so tools inside an expanded step stay on the line; first/last
|
||||
// items clip theirs to their node/tool centers so the line never dangles
|
||||
// past the timeline's ends.
|
||||
function segClass(status: string, isFirst: boolean, isLast: boolean, expandedWithTools: boolean): string {
|
||||
function segClass(
|
||||
status: string,
|
||||
isFirst: boolean,
|
||||
isLast: boolean,
|
||||
expandedWithTools: boolean
|
||||
): string {
|
||||
let color = 'bg-border'
|
||||
if (status === 'done') color = 'bg-primary/60'
|
||||
else if (status === 'running') color = 'bg-primary/40'
|
||||
@@ -154,11 +184,16 @@
|
||||
|
||||
function entryIcon(entry: ActivityEntry) {
|
||||
switch (entry.type) {
|
||||
case 'goal': return MilestoneIcon
|
||||
case 'knowledge': return SparklesIcon
|
||||
case 'complete': return FlagIcon
|
||||
case 'question': return HelpCircleIcon
|
||||
default: return WrenchIcon
|
||||
case 'goal':
|
||||
return MilestoneIcon
|
||||
case 'knowledge':
|
||||
return SparklesIcon
|
||||
case 'complete':
|
||||
return FlagIcon
|
||||
case 'question':
|
||||
return HelpCircleIcon
|
||||
default:
|
||||
return WrenchIcon
|
||||
}
|
||||
}
|
||||
function hhmm(ts: number): string {
|
||||
@@ -167,10 +202,18 @@
|
||||
}
|
||||
function hhmmss(ts: number): string {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
return new Date(ts).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
function prettyPrint(raw: string): string {
|
||||
try { return JSON.stringify(JSON.parse(raw), null, 2) } catch { return raw }
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -179,19 +222,57 @@
|
||||
{#if items.length === 0}
|
||||
<div class="flex flex-col items-center gap-2 px-3 py-6 text-center">
|
||||
<svg viewBox="0 0 64 110" class="h-14 w-auto text-muted-foreground/40" fill="none">
|
||||
<line x1="32" y1="8" x2="32" y2="102" stroke="currentColor" stroke-width="1" stroke-dasharray="2.5 4" opacity="0.35" />
|
||||
<line
|
||||
x1="32"
|
||||
y1="8"
|
||||
x2="32"
|
||||
y2="102"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="2.5 4"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<circle cx="32" cy="22" r="4" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" repeatCount="indefinite" />
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.25;0.9;0.25"
|
||||
dur="2.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="32" cy="55" r="4" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.25;0.9;0.25"
|
||||
dur="2.4s"
|
||||
begin="0.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="32" cy="55" r="4" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<animate attributeName="r" values="4;11;4" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.6;0;0.6" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
|
||||
<animate
|
||||
attributeName="r"
|
||||
values="4;11;4"
|
||||
dur="2.4s"
|
||||
begin="0.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.6;0;0.6"
|
||||
dur="2.4s"
|
||||
begin="0.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="32" cy="88" r="4" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.25;0.9;0.25"
|
||||
dur="2.4s"
|
||||
begin="1.2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
<p class="text-[11px] leading-relaxed text-muted-foreground">Waiting for activity…</p>
|
||||
@@ -209,24 +290,42 @@
|
||||
{@const expandedWithTools = open && item.tools.length > 0}
|
||||
<!-- Step node on the backbone -->
|
||||
<div class="relative" data-tl-id={item.step.id}>
|
||||
<span class="pointer-events-none absolute left-[17px] w-px {segClass(st, isFirst, isLast, expandedWithTools)}" aria-hidden="true"></span>
|
||||
<span
|
||||
class="pointer-events-none absolute left-[17px] w-px {segClass(
|
||||
st,
|
||||
isFirst,
|
||||
isLast,
|
||||
expandedWithTools
|
||||
)}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-xs {expandable ? 'cursor-pointer hover:bg-muted/30' : 'cursor-default'} {st === 'running' ? 'bg-primary/5' : ''}"
|
||||
class="relative flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-xs {expandable
|
||||
? 'cursor-pointer hover:bg-muted/30'
|
||||
: 'cursor-default'} {st === 'running' ? 'bg-primary/5' : ''}"
|
||||
onclick={() => expandable && toggleStep(item.step)}
|
||||
aria-expanded={open}
|
||||
disabled={!expandable}
|
||||
>
|
||||
<!-- Filled status node -->
|
||||
<span class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full
|
||||
{st === 'done' ? 'bg-primary'
|
||||
: st === 'running' ? 'bg-background'
|
||||
: st === 'failed' ? 'bg-destructive'
|
||||
: st === 'blocked' ? 'bg-warning/25 border border-warning'
|
||||
: st === 'skipped' || st === 'replaced' ? 'bg-muted'
|
||||
: 'bg-background border border-muted-foreground/40'}">
|
||||
<span
|
||||
class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full
|
||||
{st === 'done'
|
||||
? 'bg-primary'
|
||||
: st === 'running'
|
||||
? 'bg-background'
|
||||
: st === 'failed'
|
||||
? 'bg-destructive'
|
||||
: st === 'blocked'
|
||||
? 'bg-warning/25 border border-warning'
|
||||
: st === 'skipped' || st === 'replaced'
|
||||
? 'bg-muted'
|
||||
: 'bg-background border border-muted-foreground/40'}"
|
||||
>
|
||||
{#if st === 'running'}
|
||||
<span class="absolute -inset-0.5 animate-ping rounded-full bg-primary/30"></span>
|
||||
<span class="absolute -inset-0.5 animate-ping rounded-full bg-primary/30"
|
||||
></span>
|
||||
<Spinner class="relative size-3.5 text-primary" />
|
||||
{:else if st === 'done'}
|
||||
<CheckIcon class="size-2.5 text-primary-foreground" strokeWidth={3.5} />
|
||||
@@ -238,15 +337,28 @@
|
||||
<SlashIcon class="size-2 text-muted-foreground" strokeWidth={3} />
|
||||
{/if}
|
||||
</span>
|
||||
<span title={item.step.title} class="min-w-0 flex-1 leading-snug {open ? 'whitespace-normal' : 'truncate'} {st === 'done' ? 'text-muted-foreground' : st === 'running' ? 'font-medium text-foreground' : 'text-muted-foreground'}">
|
||||
<span
|
||||
title={item.step.title}
|
||||
class="min-w-0 flex-1 leading-snug {open
|
||||
? 'whitespace-normal'
|
||||
: 'truncate'} {st === 'done'
|
||||
? 'text-muted-foreground'
|
||||
: st === 'running'
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
>
|
||||
{item.step.title}
|
||||
</span>
|
||||
{#if hhmm(item.ts)}
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60">{hhmm(item.ts)}</span>
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
|
||||
>{hhmm(item.ts)}</span
|
||||
>
|
||||
{/if}
|
||||
{#if item.tools.length > 0}
|
||||
<span class="shrink-0 text-muted-foreground/60">
|
||||
{#if open}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
{#if open}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
|
||||
class="size-3"
|
||||
/>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -257,10 +369,19 @@
|
||||
{@const tOpen = expandedTools.has(tool.id)}
|
||||
<div class="relative" data-tl-id={tool.id}>
|
||||
<!-- Branch stub: backbone → tool -->
|
||||
<span class="pointer-events-none absolute left-[17px] top-[9.5px] h-px w-[17px] {tool.status === 'failed' ? 'bg-destructive/40' : 'bg-border'}" aria-hidden="true"></span>
|
||||
<span
|
||||
class="pointer-events-none absolute left-[17px] top-[9.5px] h-px w-[17px] {tool.status ===
|
||||
'failed'
|
||||
? 'bg-destructive/40'
|
||||
: 'bg-border'}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1.5 py-1 pl-9 pr-3 text-left text-[11px] {(tool.args || tool.detail) ? 'cursor-pointer hover:bg-muted/20' : 'cursor-default'}"
|
||||
class="flex w-full items-center gap-1.5 py-1 pl-9 pr-3 text-left text-[11px] {tool.args ||
|
||||
tool.detail
|
||||
? 'cursor-pointer hover:bg-muted/20'
|
||||
: 'cursor-default'}"
|
||||
onclick={() => (tool.args || tool.detail) && toggleTool(tool.id)}
|
||||
>
|
||||
<span class="flex size-3 shrink-0 items-center justify-center">
|
||||
@@ -272,24 +393,47 @@
|
||||
<CheckIcon class="size-2.5 text-primary/70" strokeWidth={3.5} />
|
||||
{/if}
|
||||
</span>
|
||||
<span title={tool.description} class="min-w-0 flex-1 truncate leading-snug {tool.status === 'done' ? 'text-muted-foreground' : tool.status === 'failed' ? 'text-destructive' : 'text-foreground/80'}">
|
||||
<span
|
||||
title={tool.description}
|
||||
class="min-w-0 flex-1 truncate leading-snug {tool.status === 'done'
|
||||
? 'text-muted-foreground'
|
||||
: tool.status === 'failed'
|
||||
? 'text-destructive'
|
||||
: 'text-foreground/80'}"
|
||||
>
|
||||
{tool.description}
|
||||
</span>
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50">{hhmm(tool.timestamp)}</span>
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50"
|
||||
>{hhmm(tool.timestamp)}</span
|
||||
>
|
||||
</button>
|
||||
{#if tOpen}
|
||||
<div transition:slide={{ duration: 120 }} class="flex flex-col gap-1 pb-1.5 pl-[52px] pr-3">
|
||||
<div class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70">
|
||||
<div
|
||||
transition:slide={{ duration: 120 }}
|
||||
class="flex flex-col gap-1 pb-1.5 pl-[52px] pr-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
<span class="capitalize">{tool.status}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{hhmmss(tool.timestamp)}</span>
|
||||
{#if tool.toolName}<span aria-hidden="true">·</span><code class="font-mono">{tool.toolName}</code>{/if}
|
||||
{#if tool.toolName}<span aria-hidden="true">·</span><code
|
||||
class="font-mono">{tool.toolName}</code
|
||||
>{/if}
|
||||
</div>
|
||||
{#if tool.args}
|
||||
<pre class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(tool.args)}</pre>
|
||||
<pre
|
||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(
|
||||
tool.args
|
||||
)}</pre>
|
||||
{/if}
|
||||
{#if tool.detail}
|
||||
<pre class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {tool.status === 'failed' ? 'text-destructive' : 'text-muted-foreground'}">{prettyPrint(tool.detail)}</pre>
|
||||
<pre
|
||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {tool.status ===
|
||||
'failed'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'}">{prettyPrint(tool.detail)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -304,14 +448,31 @@
|
||||
{@const Icon = entryIcon(e)}
|
||||
{@const eOpen = expandedTools.has(e.id)}
|
||||
<div class="relative" data-tl-id={e.id}>
|
||||
<span class="pointer-events-none absolute left-[17px] w-px {segClass(e.status, isFirst, isLast, false)}" aria-hidden="true"></span>
|
||||
<span
|
||||
class="pointer-events-none absolute left-[17px] w-px {segClass(
|
||||
e.status,
|
||||
isFirst,
|
||||
isLast,
|
||||
false
|
||||
)}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] {(e.args || e.detail) ? 'cursor-pointer hover:bg-muted/30' : 'cursor-default'}"
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] {e.args ||
|
||||
e.detail
|
||||
? 'cursor-pointer hover:bg-muted/30'
|
||||
: 'cursor-default'}"
|
||||
onclick={() => (e.args || e.detail) && toggleTool(e.id)}
|
||||
>
|
||||
<span class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full border bg-background
|
||||
{e.status === 'failed' ? 'border-destructive text-destructive' : e.status === 'running' ? 'border-primary text-primary' : 'border-border text-primary'}">
|
||||
<span
|
||||
class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full border bg-background
|
||||
{e.status === 'failed'
|
||||
? 'border-destructive text-destructive'
|
||||
: e.status === 'running'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-border text-primary'}"
|
||||
>
|
||||
{#if e.status === 'running'}
|
||||
<Spinner class="size-2.5" />
|
||||
{:else if e.status === 'failed'}
|
||||
@@ -320,24 +481,43 @@
|
||||
<Icon class="size-2" strokeWidth={2.5} />
|
||||
{/if}
|
||||
</span>
|
||||
<span title={e.description} class="min-w-0 flex-1 truncate leading-snug {e.status === 'done' ? 'text-muted-foreground' : 'text-foreground/80'}">
|
||||
<span
|
||||
title={e.description}
|
||||
class="min-w-0 flex-1 truncate leading-snug {e.status === 'done'
|
||||
? 'text-muted-foreground'
|
||||
: 'text-foreground/80'}"
|
||||
>
|
||||
{e.description}
|
||||
</span>
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60">{hhmm(e.timestamp)}</span>
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
|
||||
>{hhmm(e.timestamp)}</span
|
||||
>
|
||||
</button>
|
||||
{#if eOpen}
|
||||
<div transition:slide={{ duration: 120 }} class="flex flex-col gap-1 pb-1.5 pl-9 pr-3">
|
||||
<div
|
||||
transition:slide={{ duration: 120 }}
|
||||
class="flex flex-col gap-1 pb-1.5 pl-9 pr-3"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70">
|
||||
<span class="capitalize">{e.status}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{hhmmss(e.timestamp)}</span>
|
||||
{#if e.toolName}<span aria-hidden="true">·</span><code class="font-mono">{e.toolName}</code>{/if}
|
||||
{#if e.toolName}<span aria-hidden="true">·</span><code class="font-mono"
|
||||
>{e.toolName}</code
|
||||
>{/if}
|
||||
</div>
|
||||
{#if e.args}
|
||||
<pre class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(e.args)}</pre>
|
||||
<pre
|
||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(
|
||||
e.args
|
||||
)}</pre>
|
||||
{/if}
|
||||
{#if e.detail}
|
||||
<pre class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {e.status === 'failed' ? 'text-destructive' : 'text-muted-foreground'}">{prettyPrint(e.detail)}</pre>
|
||||
<pre
|
||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {e.status ===
|
||||
'failed'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'}">{prettyPrint(e.detail)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
type Row = Record<string, any>
|
||||
|
||||
const renderers: Record<string, unknown> = {
|
||||
'badge': BadgeRenderer,
|
||||
badge: BadgeRenderer,
|
||||
'health-dot': HealthDotRenderer,
|
||||
'relative-time': RelativeTimeRenderer,
|
||||
'date': DateRenderer,
|
||||
'status-badge': StatusBadgeRenderer,
|
||||
date: DateRenderer,
|
||||
'status-badge': StatusBadgeRenderer
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -59,11 +59,11 @@
|
||||
// via $derived runes internally.
|
||||
const sortBuilders = new Map<string, ReturnType<typeof table.createSort>>()
|
||||
|
||||
function getSortBuilder(key: string) {
|
||||
if (!sortBuilders.has(key)) {
|
||||
sortBuilders.set(key, table.createSort(key))
|
||||
function getSortBuilder(col: DataTableColumn<Row>) {
|
||||
if (!sortBuilders.has(col.key)) {
|
||||
sortBuilders.set(col.key, table.createSort(col.accessor ?? col.key))
|
||||
}
|
||||
return sortBuilders.get(key)!
|
||||
return sortBuilders.get(col.key)!
|
||||
}
|
||||
|
||||
let search = $state.raw(
|
||||
@@ -117,13 +117,13 @@
|
||||
</script>
|
||||
|
||||
<div class={['flex flex-col h-full min-h-0', className].filter(Boolean).join(' ')}>
|
||||
<Toolbar {table} {searchable} {paginated} onSearchChange={handleSearch}>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</Toolbar>
|
||||
<Toolbar {table} {searchable} {paginated} onSearchChange={handleSearch} {children} />
|
||||
|
||||
<div class={['min-h-0 flex-1 overflow-auto relative', bordered ? 'rounded-xl border' : ''].filter(Boolean).join(' ')}>
|
||||
<div
|
||||
class={['flex flex-col min-h-0 flex-1', bordered ? 'rounded-xl border' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<table class="w-full caption-bottom text-sm table-fixed">
|
||||
<thead class="[&_tr]:border-b">
|
||||
<tr>
|
||||
@@ -131,14 +131,16 @@
|
||||
<th
|
||||
class={[
|
||||
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap',
|
||||
'sticky top-0 z-10 bg-card/95 backdrop-blur',
|
||||
'bg-card/95',
|
||||
col.headerClass,
|
||||
colAlignClass(col)
|
||||
].filter(Boolean).join(' ')}
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={colStyle(col)}
|
||||
>
|
||||
{#if col.sortable !== false}
|
||||
{@const sb = getSortBuilder(col.key)}
|
||||
{@const sb = getSortBuilder(col)}
|
||||
<SortHeader
|
||||
label={col.header}
|
||||
sorted={sb.isActive}
|
||||
@@ -152,64 +154,86 @@
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="[&_tr:last-child]:border-0">
|
||||
{#if loading}
|
||||
{#each skeletonWidths as w, i}
|
||||
<tr class="border-b transition-colors hover:bg-transparent">
|
||||
{#each visibleCols as col (col.key)}
|
||||
<td class={[col.class, colAlignClass(col), colTruncateClass(col)].filter(Boolean).join(' ')} style={colStyle(col)}>
|
||||
<Skeleton class="h-4 {skeletonWidths[(i + visibleCols.indexOf(col)) % skeletonWidths.length]}" />
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{:else if rows.length === 0}
|
||||
<EmptyState message={emptyMessage} colspan={visibleCols.length} />
|
||||
{:else}
|
||||
{#each rows as row, idx (row.id ?? row.slug ?? `row-${idx}`)}
|
||||
<tr
|
||||
class={[
|
||||
'border-b transition-colors hover:bg-muted/50',
|
||||
onRowClick ? 'cursor-pointer' : '',
|
||||
selected === (row.id ?? row.slug) ? 'bg-muted' : ''
|
||||
].filter(Boolean).join(' ')}
|
||||
tabindex={onRowClick ? 0 : undefined}
|
||||
onclick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
onkeydown={onRowClick
|
||||
? (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRowClick(row) } }
|
||||
: undefined}
|
||||
>
|
||||
{#each visibleCols as col (col.key)}
|
||||
{@const val = resolveCellValue(row, col)}
|
||||
<td
|
||||
class={[
|
||||
'p-2 align-middle whitespace-nowrap',
|
||||
col.class,
|
||||
colAlignClass(col),
|
||||
colTruncateClass(col)
|
||||
].filter(Boolean).join(' ')}
|
||||
style={colStyle(col)}
|
||||
>
|
||||
{#if typeof col.render === 'string'}
|
||||
{@const R = renderers[col.render]}
|
||||
{#if R}
|
||||
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
|
||||
<R value={val} {row} {...(col.renderProps ?? {})} />
|
||||
</table>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<table class="w-full caption-bottom text-sm table-fixed">
|
||||
<tbody class="[&_tr:last-child]:border-0">
|
||||
{#if loading}
|
||||
{#each skeletonWidths as w, i}
|
||||
<tr class="border-b transition-colors hover:bg-transparent">
|
||||
{#each visibleCols as col (col.key)}
|
||||
<td
|
||||
class={[col.class, colAlignClass(col), colTruncateClass(col)]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={colStyle(col)}
|
||||
>
|
||||
<Skeleton
|
||||
class="h-4 {skeletonWidths[
|
||||
(i + visibleCols.indexOf(col)) % skeletonWidths.length
|
||||
]}"
|
||||
/>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{:else if rows.length === 0}
|
||||
<EmptyState message={emptyMessage} colspan={visibleCols.length} />
|
||||
{:else}
|
||||
{#each rows as row, idx (row.id ?? row.slug ?? `row-${idx}`)}
|
||||
<tr
|
||||
class={[
|
||||
'border-b transition-colors hover:bg-muted/50',
|
||||
onRowClick ? 'cursor-pointer' : '',
|
||||
selected === (row.id ?? row.slug) ? 'bg-muted' : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
tabindex={onRowClick ? 0 : undefined}
|
||||
onclick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
onkeydown={onRowClick
|
||||
? (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onRowClick(row)
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{#each visibleCols as col (col.key)}
|
||||
{@const val = resolveCellValue(row, col)}
|
||||
<td
|
||||
class={[
|
||||
'p-2 align-middle whitespace-nowrap',
|
||||
col.class,
|
||||
colAlignClass(col),
|
||||
colTruncateClass(col)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={colStyle(col)}
|
||||
>
|
||||
{#if typeof col.render === 'string'}
|
||||
{@const R = renderers[col.render]}
|
||||
{#if R}
|
||||
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
|
||||
<R value={val} {row} {...col.renderProps ?? {}} />
|
||||
{:else}
|
||||
{String(val ?? '—')}
|
||||
{/if}
|
||||
{:else if typeof col.render === 'function'}
|
||||
<col.render {row} value={val} {...col.renderProps ?? {}} />
|
||||
{:else}
|
||||
{String(val ?? '—')}
|
||||
{/if}
|
||||
{:else if typeof col.render === 'function'}
|
||||
<col.render {row} value={val} {...(col.renderProps ?? {})} />
|
||||
{:else}
|
||||
{String(val ?? '—')}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if paginated}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import * as Select from '$lib/components/ui/select'
|
||||
import type { TableHandler } from '@vincjo/datatables'
|
||||
|
||||
let { table, class: className }: { table: TableHandler<Record<string, unknown>>; class?: string } = $props()
|
||||
let {
|
||||
table,
|
||||
class: className
|
||||
}: { table: TableHandler<Record<string, unknown>>; class?: string } = $props()
|
||||
|
||||
const options = [10, 20, 50, 100]
|
||||
let value = $state('20')
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
</script>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button size="sm" disabled={deciding === row.id} onclick={() => onApprove?.(row.id)}>Approve</Button>
|
||||
<Button size="sm" variant="destructive" disabled={deciding === row.id} onclick={() => onDeny?.(row.id)}>Deny</Button>
|
||||
<Button size="sm" disabled={deciding === row.id} onclick={() => onApprove?.(row.id)}
|
||||
>Approve</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={deciding === row.id}
|
||||
onclick={() => onDeny?.(row.id)}>Deny</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Badge, type BadgeVariant } from '$lib/components/ui/badge'
|
||||
|
||||
let { value, variant = 'outline' as BadgeVariant }: { value: unknown; variant?: BadgeVariant } = $props()
|
||||
let { value, variant = 'outline' as BadgeVariant }: { value: unknown; variant?: BadgeVariant } =
|
||||
$props()
|
||||
</script>
|
||||
|
||||
<Badge {variant}>{String(value ?? '—')}</Badge>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</script>
|
||||
|
||||
{#if health}
|
||||
<span class="flex items-center gap-1.5 text-xs" title={title}>
|
||||
<span class="flex items-center gap-1.5 text-xs" {title}>
|
||||
<span class="size-2 shrink-0 rounded-full {dot[row.health ?? ''] ?? ''}"></span>
|
||||
<span class="text-muted-foreground">{relativeTime(lastCheck)}</span>
|
||||
</span>
|
||||
|
||||
@@ -19,8 +19,13 @@
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
{#if row.state === 'raised'}
|
||||
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onAck?.(row.id)}>Ack</Button>
|
||||
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onAck?.(row.id)}
|
||||
>Ack</Button
|
||||
>
|
||||
{/if}
|
||||
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onMute?.(row.id)}>Mute 1h</Button>
|
||||
<Button size="sm" disabled={acting === row.id} onclick={() => onResolve?.(row.id)}>Resolve</Button>
|
||||
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onMute?.(row.id)}
|
||||
>Mute 1h</Button
|
||||
>
|
||||
<Button size="sm" disabled={acting === row.id} onclick={() => onResolve?.(row.id)}>Resolve</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
|
||||
let { value, kind = 'default' }: { value: unknown; kind?: 'risk' | 'severity' | 'execution' | 'state' | 'type' | 'default' } = $props()
|
||||
let {
|
||||
value,
|
||||
kind = 'default'
|
||||
}: { value: unknown; kind?: 'risk' | 'severity' | 'execution' | 'state' | 'type' | 'default' } =
|
||||
$props()
|
||||
|
||||
const v = $derived(String(value ?? ''))
|
||||
|
||||
const variantMap: Record<string, Record<string, 'default' | 'secondary' | 'destructive' | 'outline'>> = {
|
||||
const variantMap: Record<
|
||||
string,
|
||||
Record<string, 'default' | 'secondary' | 'destructive' | 'outline'>
|
||||
> = {
|
||||
risk: {
|
||||
destructive: 'destructive',
|
||||
config_mutation: 'secondary',
|
||||
default: 'default',
|
||||
default: 'default'
|
||||
},
|
||||
severity: {
|
||||
critical: 'destructive',
|
||||
warning: 'secondary',
|
||||
info: 'default',
|
||||
default: 'default',
|
||||
default: 'default'
|
||||
},
|
||||
execution: {
|
||||
failed: 'destructive',
|
||||
@@ -25,19 +32,19 @@
|
||||
completed: 'default',
|
||||
running: 'secondary',
|
||||
approved: 'secondary',
|
||||
default: 'outline',
|
||||
default: 'outline'
|
||||
},
|
||||
state: {
|
||||
active: 'default',
|
||||
healthy: 'default',
|
||||
default: 'outline',
|
||||
default: 'outline'
|
||||
},
|
||||
type: {
|
||||
runbook: 'secondary',
|
||||
investigation: 'default',
|
||||
default: 'outline',
|
||||
default: 'outline'
|
||||
},
|
||||
default: { default: 'default' },
|
||||
default: { default: 'default' }
|
||||
}
|
||||
|
||||
const variant = $derived.by(() => {
|
||||
|
||||
@@ -9,12 +9,14 @@
|
||||
import { iconPositions, resetIconLayout } from '$lib/stores/icons'
|
||||
import { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows'
|
||||
import { summary } from '$lib/stores/context'
|
||||
import GraphBackground from '../GraphBackground.svelte'
|
||||
import { getBackground } from '$lib/stores/background.svelte'
|
||||
import { patternCss } from '$lib/desktop-patterns'
|
||||
import DesktopIcon from './DesktopIcon.svelte'
|
||||
import TaskLauncher from './TaskLauncher.svelte'
|
||||
import WindowLayer from './WindowLayer.svelte'
|
||||
import DockedLayer from './DockedLayer.svelte'
|
||||
import Taskbar from './Taskbar.svelte'
|
||||
import * as ContextMenu from '$lib/components/ui/context-menu'
|
||||
import LayersIcon from '@lucide/svelte/icons/layers'
|
||||
import Rows3Icon from '@lucide/svelte/icons/rows-3'
|
||||
import MonitorIcon from '@lucide/svelte/icons/monitor'
|
||||
@@ -22,37 +24,18 @@
|
||||
import Undo2Icon from '@lucide/svelte/icons/undo-2'
|
||||
import Redo2Icon from '@lucide/svelte/icons/redo-2'
|
||||
|
||||
// Clicking the bare desktop (not an icon, not a window) blurs the focused
|
||||
// window — the familiar "click empty desktop to deselect" affordance.
|
||||
function onSurfaceClick(e: MouseEvent) {
|
||||
if (e.currentTarget === e.target) wm.blur()
|
||||
}
|
||||
|
||||
// Right-click menu, bare desktop only (same currentTarget===target gate as
|
||||
// onSurfaceClick above — icons and windows sit on pointer-events-auto
|
||||
// layers above the otherwise pointer-events-none surface, so a right-click
|
||||
// that lands on either of them never reaches here). canUndo/canRedo are
|
||||
// plain wmkit method calls (not stores), so they're snapshotted once at
|
||||
// open time rather than read reactively in the template.
|
||||
let menuPos = $state<{ x: number; y: number } | null>(null)
|
||||
// canUndo/canRedo are plain wmkit method calls (not stores), so they're
|
||||
// snapshotted once when the menu opens (onOpenChange) rather than read
|
||||
// reactively in the template. bits-ui auto-dismisses on item select and
|
||||
// on Escape / click-away, so the old manual menuPos/closeMenu/runMenuAction
|
||||
// machinery is gone.
|
||||
let menuCanUndo = $state(false)
|
||||
let menuCanRedo = $state(false)
|
||||
|
||||
function onSurfaceContextMenu(e: MouseEvent) {
|
||||
if (e.currentTarget !== e.target) return
|
||||
e.preventDefault()
|
||||
function onOpenChange(open: boolean) {
|
||||
if (!open) return
|
||||
menuCanUndo = wm.canUndo()
|
||||
menuCanRedo = wm.canRedo()
|
||||
menuPos = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuPos = null
|
||||
}
|
||||
|
||||
function runMenuAction(fn: () => void) {
|
||||
fn()
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Z / Shift+Z for window-arrangement undo/redo (move, resize,
|
||||
@@ -61,30 +44,90 @@
|
||||
// fights the browser's own text-undo inside the task input or a form
|
||||
// field.
|
||||
function onWindowKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && menuPos) {
|
||||
closeMenu()
|
||||
return
|
||||
}
|
||||
const target = e.target as HTMLElement | null
|
||||
const editable = !!target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
const editable =
|
||||
!!target &&
|
||||
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
if (editable) return
|
||||
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'z') return
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) wm.redo()
|
||||
else wm.undo()
|
||||
}
|
||||
|
||||
// Configurable in Settings → Appearance (see background.svelte.ts). Two
|
||||
// layers, not one, because rotation and the fade mask need different
|
||||
// geometry:
|
||||
// - outer: exactly the viewport box. Carries the fade mask, since a
|
||||
// vignette has to be centered on what's actually visible.
|
||||
// - inner: oversized (200%) and centered before rotating, so turning the
|
||||
// pattern doesn't pull its straight edges into view at the corners —
|
||||
// a viewport-sized box rotated in place would do exactly that.
|
||||
const bgActive = $derived.by(() => {
|
||||
const bg = getBackground()
|
||||
return bg.pattern !== 'none' || bg.fillColor !== null
|
||||
})
|
||||
const bgOuterStyle = $derived.by(() => {
|
||||
const bg = getBackground()
|
||||
if (bg.fade <= 0) return ''
|
||||
const stop = Math.round(100 - bg.fade * 70)
|
||||
const mask = `radial-gradient(circle at 50% 50%, black 0%, black ${stop}%, transparent 100%)`
|
||||
return `mask-image:${mask};-webkit-mask-image:${mask};`
|
||||
})
|
||||
const bgInnerStyle = $derived.by(() => {
|
||||
const bg = getBackground()
|
||||
const css = patternCss(bg.pattern, bg.color, bg.scale)
|
||||
return `inset:-50%;width:200%;height:200%;opacity:${bg.opacity};background-color:${bg.fillColor ?? 'transparent'};transform:rotate(${bg.rotation}deg);${css}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onWindowKeydown} onclick={closeMenu} />
|
||||
<svelte:window onkeydown={onWindowKeydown} />
|
||||
|
||||
<div class="fixed inset-0 flex flex-col">
|
||||
<div
|
||||
class="relative min-h-0 flex-1 overflow-hidden"
|
||||
role="presentation"
|
||||
onclick={onSurfaceClick}
|
||||
oncontextmenu={onSurfaceContextMenu}
|
||||
>
|
||||
<GraphBackground />
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden" role="presentation">
|
||||
{#if bgActive}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 z-0 overflow-hidden"
|
||||
aria-hidden="true"
|
||||
style={bgOuterStyle}
|
||||
>
|
||||
<div class="absolute" style={bgInnerStyle}></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ContextMenu.Root {onOpenChange}>
|
||||
<!-- The bare-desktop hit area. Placed before the icons/windows layers
|
||||
so they (pointer-events-auto, later in DOM → paint on top) catch
|
||||
their own right-clicks — the trigger only sees right-clicks that
|
||||
fall through to bare desktop. This DOM-structure gate replaces the
|
||||
old `currentTarget === target` event check. Left-click on bare
|
||||
desktop blurs the focused window (the familiar "click empty
|
||||
desktop to deselect" affordance). -->
|
||||
<ContextMenu.Trigger class="absolute inset-0 z-0" onclick={() => wm.blur()}
|
||||
></ContextMenu.Trigger>
|
||||
<ContextMenu.Content class="min-w-48">
|
||||
<ContextMenu.Item onSelect={() => wm.arrange('cascade')}>
|
||||
<LayersIcon class="size-4" /> Cascade windows
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onSelect={() => wm.arrange('tile')}>
|
||||
<Rows3Icon class="size-4" /> Tile windows
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onSelect={toggleShowDesktop}>
|
||||
<MonitorIcon class="size-4" /> Show desktop
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onSelect={resetIconLayout}>
|
||||
<RotateCcwIcon class="size-4" /> Reset icon layout
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item disabled={!menuCanUndo} onSelect={() => wm.undo()}>
|
||||
<Undo2Icon class="size-4" /> Undo
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item disabled={!menuCanRedo} onSelect={() => wm.redo()}>
|
||||
<Redo2Icon class="size-4" /> Redo
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
|
||||
<div class="pointer-events-none absolute inset-0 z-0">
|
||||
{#each $apps as app (app.id)}
|
||||
@@ -107,58 +150,3 @@
|
||||
|
||||
<Taskbar />
|
||||
</div>
|
||||
|
||||
{#if menuPos}
|
||||
<div
|
||||
class="fixed z-50 min-w-48 rounded-md border bg-popover p-1 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10"
|
||||
style="left: {menuPos.x}px; top: {menuPos.y}px"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => runMenuAction(() => wm.arrange('cascade'))}
|
||||
>
|
||||
<LayersIcon class="size-4" /> Cascade windows
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => runMenuAction(() => wm.arrange('tile'))}
|
||||
>
|
||||
<Rows3Icon class="size-4" /> Tile windows
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => runMenuAction(toggleShowDesktop)}
|
||||
>
|
||||
<MonitorIcon class="size-4" /> Show desktop
|
||||
</button>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => runMenuAction(resetIconLayout)}
|
||||
>
|
||||
<RotateCcwIcon class="size-4" /> Reset icon layout
|
||||
</button>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!menuCanUndo}
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||
onclick={() => runMenuAction(() => wm.undo())}
|
||||
>
|
||||
<Undo2Icon class="size-4" /> Undo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!menuCanRedo}
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||
onclick={() => runMenuAction(() => wm.redo())}
|
||||
>
|
||||
<Redo2Icon class="size-4" /> Redo
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -88,10 +88,14 @@
|
||||
onkeydown={onKeydown}
|
||||
title={app.title}
|
||||
>
|
||||
<span class="relative flex size-10 items-center justify-center rounded-xl border bg-card/80 text-foreground shadow-sm backdrop-blur">
|
||||
<span
|
||||
class="relative flex size-10 items-center justify-center rounded-xl border bg-card/80 text-foreground shadow-sm backdrop-blur"
|
||||
>
|
||||
<app.icon class="size-5" />
|
||||
{#if badge > 0}
|
||||
<span class="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground">
|
||||
<span
|
||||
class="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground"
|
||||
>
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
{@const C = mod.default}
|
||||
<C />
|
||||
{:catch error}
|
||||
<div class="flex h-full min-h-0 items-center justify-center p-4 text-center text-sm text-destructive">
|
||||
<div
|
||||
class="flex h-full min-h-0 items-center justify-center p-4 text-center text-sm text-destructive"
|
||||
>
|
||||
Failed to load app: {(error as Error).message}
|
||||
</div>
|
||||
{/await}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
messages={[]}
|
||||
streaming={false}
|
||||
connectionState="connected"
|
||||
onSend={onSend}
|
||||
{onSend}
|
||||
onCancel={() => {}}
|
||||
onReconnect={() => {}}
|
||||
onDismissError={() => {}}
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
class="max-h-52 min-h-24 resize-none field-sizing-fixed border-0 bg-transparent px-4 py-3.5 text-base shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
<div class="flex items-center justify-between px-3 pb-3">
|
||||
<span class="text-[11px] text-muted-foreground">Enter to start · Shift+Enter for newline</span>
|
||||
<span class="text-[11px] text-muted-foreground">Enter to start · Shift+Enter for newline</span
|
||||
>
|
||||
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Start task">
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
wm.focus(id)
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="flex h-11 shrink-0 items-center gap-1.5 border-t bg-muted/30 px-2">
|
||||
@@ -77,14 +76,19 @@
|
||||
class="flex h-8 max-w-56 items-center gap-1.5 rounded-md border px-2 font-mono text-xs transition-colors {$wmState.focusedId ===
|
||||
win.id && win.stage !== 'minimized'
|
||||
? 'border-primary/50 bg-primary/10 text-foreground'
|
||||
: 'border-transparent bg-card/60 text-muted-foreground hover:bg-muted'} {win.stage === 'minimized' ? 'opacity-60' : ''}"
|
||||
: 'border-transparent bg-card/60 text-muted-foreground hover:bg-muted'} {win.stage ===
|
||||
'minimized'
|
||||
? 'opacity-60'
|
||||
: ''}"
|
||||
onclick={() => toggle(win.id, win)}
|
||||
title={win.title}
|
||||
>
|
||||
{#if Icon}<Icon class="size-3.5 shrink-0" />{/if}
|
||||
<span class="min-w-0 truncate">{truncateMiddle(win.title, 26)}</span>
|
||||
{#if badge > 0}
|
||||
<span class="flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold text-destructive-foreground">
|
||||
<span
|
||||
class="flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold text-destructive-foreground"
|
||||
>
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -109,12 +113,12 @@
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center gap-1.5 rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onclick={() => toggleTheme()}
|
||||
title="Cycle theme"
|
||||
title="Cycle theme ({THEME_LABELS[getTheme()]})"
|
||||
aria-label="Cycle theme, currently {THEME_LABELS[getTheme()]}"
|
||||
>
|
||||
<PaletteIcon class="size-4" />
|
||||
<span class="hidden text-xs sm:inline">{THEME_LABELS[getTheme()]}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,7 +9,14 @@
|
||||
// session:<id> -> SessionChatWindow (windows.ts openTaskWindow)
|
||||
// new-task -> NewTaskChat (windows.ts openNewTaskWindow)
|
||||
// anything else -> entity slug -> EntityDetailContent
|
||||
import { wm, dk, wmState, openEntityWindow, NEW_TASK_WINDOW_ID, SESSION_PREFIX } from '$lib/stores/windows'
|
||||
import {
|
||||
wm,
|
||||
dk,
|
||||
wmState,
|
||||
openEntityWindow,
|
||||
NEW_TASK_WINDOW_ID,
|
||||
SESSION_PREFIX
|
||||
} from '$lib/stores/windows'
|
||||
import { appById, appIdFromWindowId } from '$lib/apps'
|
||||
import EntityDetailContent from '../EntityDetailContent.svelte'
|
||||
import SessionChatWindow from '../SessionChatWindow.svelte'
|
||||
@@ -32,6 +39,22 @@
|
||||
if (appId && !idx.has(appId)) wm.close(id)
|
||||
}
|
||||
})
|
||||
|
||||
// Keep an open app window's title in sync with its registry entry. The
|
||||
// title is copied into the window at open time and then persisted, so a
|
||||
// rename (e.g. "Knowledge Base" -> "Fleet") would otherwise stay stuck in
|
||||
// the titlebar/taskbar of any already-open or hydrated window until it was
|
||||
// closed and reopened. Mirrors the task-window title sync in windows.ts.
|
||||
$effect(() => {
|
||||
const idx = $appById
|
||||
for (const id of $wmState.order) {
|
||||
const appId = appIdFromWindowId(id)
|
||||
const app = appId ? idx.get(appId) : undefined
|
||||
if (app && $wmState.windows[id]?.title !== app.title) {
|
||||
wm.update(id, { title: app.title })
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div use:dk.desktop class="absolute inset-0 z-40 pointer-events-none">
|
||||
@@ -41,8 +64,15 @@
|
||||
{@const app = appId ? $appById.get(appId) : undefined}
|
||||
{#if win && (!appId || app)}
|
||||
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
|
||||
<header data-wm-drag class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5">
|
||||
<span data-wm-title class="flex min-w-0 flex-1 items-center self-stretch truncate font-mono text-xs font-medium">{win.title}</span>
|
||||
<header
|
||||
data-wm-drag
|
||||
class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5"
|
||||
>
|
||||
<span
|
||||
data-wm-title
|
||||
class="flex min-w-0 flex-1 items-center self-stretch truncate font-mono text-xs font-medium"
|
||||
>{win.title}</span
|
||||
>
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
626
web/src/lib/components/knowledge/WikiCleanup.svelte
Normal file
626
web/src/lib/components/knowledge/WikiCleanup.svelte
Normal 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 "{m.title}" 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 "{m.title}" 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>
|
||||
124
web/src/lib/components/knowledge/WikiContextRail.svelte
Normal file
124
web/src/lib/components/knowledge/WikiContextRail.svelte
Normal 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>
|
||||
139
web/src/lib/components/knowledge/WikiNewDialog.svelte
Normal file
139
web/src/lib/components/knowledge/WikiNewDialog.svelte
Normal 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>
|
||||
201
web/src/lib/components/knowledge/WikiOverview.svelte
Normal file
201
web/src/lib/components/knowledge/WikiOverview.svelte
Normal 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>
|
||||
119
web/src/lib/components/knowledge/WikiQuickOpen.svelte
Normal file
119
web/src/lib/components/knowledge/WikiQuickOpen.svelte
Normal 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>
|
||||
485
web/src/lib/components/knowledge/WikiReader.svelte
Normal file
485
web/src/lib/components/knowledge/WikiReader.svelte
Normal 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>
|
||||
198
web/src/lib/components/knowledge/WikiTree.svelte
Normal file
198
web/src/lib/components/knowledge/WikiTree.svelte
Normal 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 “{filter}”.
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
50
web/src/lib/components/knowledge/kinds.ts
Normal file
50
web/src/lib/components/knowledge/kinds.ts
Normal 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'
|
||||
}
|
||||
197
web/src/lib/components/knowledge/wikiText.ts
Normal file
197
web/src/lib/components/knowledge/wikiText.ts
Normal 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
|
||||
}
|
||||
@@ -1,49 +1,50 @@
|
||||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
import { type VariantProps, tv } from 'tailwind-variants'
|
||||
|
||||
export const badgeVariants = tv({
|
||||
base: "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
|
||||
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
export const badgeVariants = tv({
|
||||
base: 'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none',
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
|
||||
destructive:
|
||||
'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
|
||||
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
|
||||
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
})
|
||||
|
||||
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||
export type BadgeVariant = VariantProps<typeof badgeVariants>['variant']
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAnchorAttributes } from 'svelte/elements'
|
||||
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
href,
|
||||
class: className,
|
||||
variant = "default",
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: BadgeVariant;
|
||||
} = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
href,
|
||||
class: className,
|
||||
variant = 'default',
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: BadgeVariant
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={href ? "a" : "span"}
|
||||
bind:this={ref}
|
||||
data-slot="badge"
|
||||
{href}
|
||||
class={cn(badgeVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
this={href ? 'a' : 'span'}
|
||||
bind:this={ref}
|
||||
data-slot="badge"
|
||||
{href}
|
||||
class={cn(badgeVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||
export { default as Badge } from './badge.svelte'
|
||||
export { badgeVariants, type BadgeVariant } from './badge.svelte'
|
||||
|
||||
@@ -1,82 +1,89 @@
|
||||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'
|
||||
import { type VariantProps, tv } from 'tailwind-variants'
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
|
||||
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
outline:
|
||||
'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||
ghost:
|
||||
'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||
destructive:
|
||||
'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
'h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: 'h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5',
|
||||
lg: 'h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
icon: 'size-9',
|
||||
'icon-xs':
|
||||
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm':
|
||||
'size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md',
|
||||
'icon-lg': 'size-10'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
})
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant']
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>['size']
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = "button",
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
let {
|
||||
class: className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = 'button',
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props()
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? "link" : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? 'link' : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants
|
||||
} from './button.svelte'
|
||||
|
||||
export {
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
};
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-action"
|
||||
class={cn(
|
||||
"cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="card-action"
|
||||
class={cn(
|
||||
'cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-content"
|
||||
class={cn("px-6 group-data-[size=sm]/card:px-4", className)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="card-content"
|
||||
class={cn('px-6 group-data-[size=sm]/card:px-4', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props()
|
||||
</script>
|
||||
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn('text-muted-foreground text-sm', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</p>
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-footer"
|
||||
class={cn("rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4 flex items-center", className)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="card-footer"
|
||||
class={cn(
|
||||
'rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4 flex items-center',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-header"
|
||||
class={cn(
|
||||
"gap-1 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="card-header"
|
||||
class={cn(
|
||||
'gap-1 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-title"
|
||||
class={cn("text-base leading-normal font-medium group-data-[size=sm]/card:text-sm", className)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="card-title"
|
||||
class={cn('text-base leading-normal font-medium group-data-[size=sm]/card:text-sm', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
size = "default",
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
size = 'default',
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: 'default' | 'sm' } = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
class={cn("ring-foreground/10 bg-card text-card-foreground gap-6 overflow-hidden rounded-xl py-6 text-sm shadow-xs ring-1 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
class={cn(
|
||||
'ring-foreground/10 bg-card text-card-foreground gap-6 overflow-hidden rounded-xl py-6 text-sm shadow-xs ring-1 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import Root from "./card.svelte";
|
||||
import Content from "./card-content.svelte";
|
||||
import Description from "./card-description.svelte";
|
||||
import Footer from "./card-footer.svelte";
|
||||
import Header from "./card-header.svelte";
|
||||
import Title from "./card-title.svelte";
|
||||
import Action from "./card-action.svelte";
|
||||
import Root from './card.svelte'
|
||||
import Content from './card-content.svelte'
|
||||
import Description from './card-description.svelte'
|
||||
import Footer from './card-footer.svelte'
|
||||
import Header from './card-header.svelte'
|
||||
import Title from './card-title.svelte'
|
||||
import Action from './card-action.svelte'
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Description,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Action,
|
||||
//
|
||||
Root as Card,
|
||||
Content as CardContent,
|
||||
Description as CardDescription,
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction,
|
||||
};
|
||||
Root,
|
||||
Content,
|
||||
Description,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Action,
|
||||
//
|
||||
Root as Card,
|
||||
Content as CardContent,
|
||||
Description as CardDescription,
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked, indeterminate })}
|
||||
<div data-slot="checkbox-indicator" class="flex items-center justify-center text-current transition-none">
|
||||
<div
|
||||
data-slot="checkbox-indicator"
|
||||
class="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
{#if indeterminate}
|
||||
<MinusIcon class="size-3.5" />
|
||||
{:else if checked}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||
import { Collapsible as CollapsiblePrimitive } from 'bits-ui'
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
|
||||
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props()
|
||||
</script>
|
||||
|
||||
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||
import { Collapsible as CollapsiblePrimitive } from 'bits-ui'
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
|
||||
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props()
|
||||
</script>
|
||||
|
||||
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||
import { Collapsible as CollapsiblePrimitive } from 'bits-ui'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
open = $bindable(false),
|
||||
...restProps
|
||||
}: CollapsiblePrimitive.RootProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
open = $bindable(false),
|
||||
...restProps
|
||||
}: CollapsiblePrimitive.RootProps = $props()
|
||||
</script>
|
||||
|
||||
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import Root from "./collapsible.svelte";
|
||||
import Trigger from "./collapsible-trigger.svelte";
|
||||
import Content from "./collapsible-content.svelte";
|
||||
import Root from './collapsible.svelte'
|
||||
import Trigger from './collapsible-trigger.svelte'
|
||||
import Content from './collapsible-content.svelte'
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Trigger,
|
||||
//
|
||||
Root as Collapsible,
|
||||
Content as CollapsibleContent,
|
||||
Trigger as CollapsibleTrigger,
|
||||
};
|
||||
Root,
|
||||
Content,
|
||||
Trigger,
|
||||
//
|
||||
Root as Collapsible,
|
||||
Content as CollapsibleContent,
|
||||
Trigger as CollapsibleTrigger
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js'
|
||||
import type { Snippet } from 'svelte'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
class: className,
|
||||
inset,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<ContextMenuPrimitive.CheckboxItemProps> & {
|
||||
inset?: boolean
|
||||
children?: Snippet
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
bind:ref
|
||||
bind:checked
|
||||
bind:indeterminate
|
||||
data-slot="context-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span class="absolute right-2 pointer-events-none">
|
||||
{#if checked}
|
||||
<CheckIcon />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.()}
|
||||
{/snippet}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
|
||||
import { cn } from '$lib/utils.js'
|
||||
import ContextMenuPortal from './context-menu-portal.svelte'
|
||||
import type { ComponentProps } from 'svelte'
|
||||
import type { WithoutChildrenOrChild } from '$lib/utils.js'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
portalProps,
|
||||
class: className,
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.ContentProps & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof ContextMenuPortal>>
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<ContextMenuPortal {...portalProps}>
|
||||
<ContextMenuPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="context-menu-content"
|
||||
class={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-36 rounded-md p-1 shadow-md ring-1 duration-100 z-50 overflow-x-hidden overflow-y-auto outline-none',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</ContextMenuPortal>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
|
||||
import { cn } from '$lib/utils.js'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.GroupHeadingProps & {
|
||||
inset?: boolean
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.GroupHeading
|
||||
bind:ref
|
||||
data-slot="context-menu-group-heading"
|
||||
data-inset={inset}
|
||||
class={cn('text-foreground px-2 py-1.5 text-sm font-medium data-inset:ps-8', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: ContextMenuPrimitive.GroupProps = $props()
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Group bind:ref data-slot="context-menu-group" {...restProps} />
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
|
||||
import { cn } from '$lib/utils.js'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.ItemProps & {
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-2 rounded-sm px-2 py-1.5 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
inset?: boolean
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
class={cn(
|
||||
'text-muted-foreground px-2 py-1.5 text-xs font-medium data-inset:pl-8 data-inset:pl-8',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
|
||||
|
||||
let { ...restProps }: ContextMenuPrimitive.PortalProps = $props()
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Portal {...restProps} />
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(''),
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.RadioGroupProps = $props()
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="context-menu-radio-group"
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
|
||||
import { cn, type WithoutChild } from '$lib/utils.js'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChild<ContextMenuPrimitive.RadioItemProps> & {
|
||||
inset?: boolean
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
bind:ref
|
||||
data-slot="context-menu-radio-item"
|
||||
data-inset={inset}
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span class="absolute right-2 pointer-events-none">
|
||||
{#if checked}
|
||||
<CheckIcon />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.({ checked })}
|
||||
{/snippet}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
|
||||
import { cn } from '$lib/utils.js'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.SeparatorProps = $props()
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Separator
|
||||
bind:ref
|
||||
data-slot="context-menu-separator"
|
||||
class={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props()
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="context-menu-shortcut"
|
||||
class={cn(
|
||||
'text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
|
||||
import { cn } from '$lib/utils.js'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.SubContentProps = $props()
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.SubContent
|
||||
bind:ref
|
||||
data-slot="context-menu-sub-content"
|
||||
class={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground min-w-32 rounded-md border p-1 shadow-lg duration-100',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
|
||||
import { cn, type WithoutChild } from '$lib/utils.js'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<ContextMenuPrimitive.SubTriggerProps> & {
|
||||
inset?: boolean
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
bind:ref
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground rounded-sm px-2 py-1.5 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-inset:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronRightIcon class="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
|
||||
|
||||
let { open = $bindable(false), ...restProps }: ContextMenuPrimitive.SubProps = $props()
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Sub bind:open {...restProps} />
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
|
||||
import { cn } from '$lib/utils.js'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: ContextMenuPrimitive.TriggerProps = $props()
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="context-menu-trigger"
|
||||
class={cn('cn-context-menu-trigger select-none', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui'
|
||||
|
||||
let { open = $bindable(false), ...restProps }: ContextMenuPrimitive.RootProps = $props()
|
||||
</script>
|
||||
|
||||
<ContextMenuPrimitive.Root bind:open {...restProps} />
|
||||
52
web/src/lib/components/ui/context-menu/index.ts
Normal file
52
web/src/lib/components/ui/context-menu/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import Root from './context-menu.svelte'
|
||||
import Sub from './context-menu-sub.svelte'
|
||||
import Portal from './context-menu-portal.svelte'
|
||||
import Trigger from './context-menu-trigger.svelte'
|
||||
import Group from './context-menu-group.svelte'
|
||||
import RadioGroup from './context-menu-radio-group.svelte'
|
||||
import Item from './context-menu-item.svelte'
|
||||
import GroupHeading from './context-menu-group-heading.svelte'
|
||||
import Content from './context-menu-content.svelte'
|
||||
import Shortcut from './context-menu-shortcut.svelte'
|
||||
import RadioItem from './context-menu-radio-item.svelte'
|
||||
import Separator from './context-menu-separator.svelte'
|
||||
import SubContent from './context-menu-sub-content.svelte'
|
||||
import SubTrigger from './context-menu-sub-trigger.svelte'
|
||||
import CheckboxItem from './context-menu-checkbox-item.svelte'
|
||||
import Label from './context-menu-label.svelte'
|
||||
|
||||
export {
|
||||
Root,
|
||||
Sub,
|
||||
Portal,
|
||||
Item,
|
||||
GroupHeading,
|
||||
Label,
|
||||
Group,
|
||||
Trigger,
|
||||
Content,
|
||||
Shortcut,
|
||||
Separator,
|
||||
RadioItem,
|
||||
SubContent,
|
||||
SubTrigger,
|
||||
RadioGroup,
|
||||
CheckboxItem,
|
||||
//
|
||||
Root as ContextMenu,
|
||||
Sub as ContextMenuSub,
|
||||
Portal as ContextMenuPortal,
|
||||
Item as ContextMenuItem,
|
||||
GroupHeading as ContextMenuGroupHeading,
|
||||
Group as ContextMenuGroup,
|
||||
Content as ContextMenuContent,
|
||||
Trigger as ContextMenuTrigger,
|
||||
Shortcut as ContextMenuShortcut,
|
||||
RadioItem as ContextMenuRadioItem,
|
||||
Separator as ContextMenuSeparator,
|
||||
RadioGroup as ContextMenuRadioGroup,
|
||||
SubContent as ContextMenuSubContent,
|
||||
SubTrigger as ContextMenuSubTrigger,
|
||||
CheckboxItem as ContextMenuCheckboxItem,
|
||||
Label as ContextMenuLabel
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
type = "button",
|
||||
...restProps
|
||||
}: DialogPrimitive.CloseProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
type = 'button',
|
||||
...restProps
|
||||
}: DialogPrimitive.CloseProps = $props()
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {type} {...restProps} />
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import DialogPortal from "./dialog-portal.svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import * as Dialog from "./index.js";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui'
|
||||
import DialogPortal from './dialog-portal.svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
import * as Dialog from './index.js'
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js'
|
||||
import type { ComponentProps } from 'svelte'
|
||||
import { Button } from '$lib/components/ui/button/index.js'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
|
||||
children: Snippet;
|
||||
showCloseButton?: boolean;
|
||||
} = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>
|
||||
children: Snippet
|
||||
showCloseButton?: boolean
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<DialogPortal {...portalProps}>
|
||||
<Dialog.Overlay />
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dialog-content"
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-6 rounded-xl p-6 text-sm ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if showCloseButton}
|
||||
<DialogPrimitive.Close data-slot="dialog-close">
|
||||
{#snippet child({ props })}
|
||||
<Button variant="ghost" class="absolute top-4 right-4" size="icon-sm" {...props}>
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DialogPrimitive.Close>
|
||||
{/if}
|
||||
</DialogPrimitive.Content>
|
||||
<Dialog.Overlay />
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dialog-content"
|
||||
class={cn(
|
||||
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-6 rounded-xl p-6 text-sm ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if showCloseButton}
|
||||
<DialogPrimitive.Close data-slot="dialog-close">
|
||||
{#snippet child({ props })}
|
||||
<Button variant="ghost" class="absolute top-4 right-4" size="icon-sm" {...props}>
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DialogPrimitive.Close>
|
||||
{/if}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui'
|
||||
import { cn } from '$lib/utils.js'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.DescriptionProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.DescriptionProps = $props()
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="dialog-description"
|
||||
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="dialog-description"
|
||||
class={cn(
|
||||
'text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui'
|
||||
import { Button } from '$lib/components/ui/button/index.js'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
showCloseButton = false,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
showCloseButton?: boolean;
|
||||
} = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
showCloseButton = false,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
showCloseButton?: boolean
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-footer"
|
||||
class={cn("gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="dialog-footer"
|
||||
class={cn('gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if showCloseButton}
|
||||
<DialogPrimitive.Close>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" {...props}>Close</Button>
|
||||
{/snippet}
|
||||
</DialogPrimitive.Close>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
{#if showCloseButton}
|
||||
<DialogPrimitive.Close>
|
||||
{#snippet child({ props })}
|
||||
<Button variant="outline" {...props}>Close</Button>
|
||||
{/snippet}
|
||||
</DialogPrimitive.Close>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from 'svelte/elements'
|
||||
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-header"
|
||||
class={cn("gap-2 flex flex-col", className)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
data-slot="dialog-header"
|
||||
class={cn('gap-2 flex flex-col', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui'
|
||||
import { cn } from '$lib/utils.js'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.OverlayProps = $props();
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.OverlayProps = $props()
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="dialog-overlay"
|
||||
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
data-slot="dialog-overlay"
|
||||
class={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui'
|
||||
|
||||
let { ...restProps }: DialogPrimitive.PortalProps = $props();
|
||||
let { ...restProps }: DialogPrimitive.PortalProps = $props()
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Portal {...restProps} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user