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
|
FROM knowledge_entities ke
|
||||||
JOIN entities e ON e.id = ke.entity_id
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
WHERE ($1 = '' OR ke.source = $1)
|
WHERE ($1 = '' OR ke.source = $1)
|
||||||
|
AND ke.deleted_at IS NULL
|
||||||
ORDER BY ke.updated_at DESC
|
ORDER BY ke.updated_at DESC
|
||||||
LIMIT $2`, source, limit)
|
LIMIT $2`, source, limit)
|
||||||
if err != nil {
|
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.source = 'nomos-agent'),
|
||||||
COUNT(*) FILTER (WHERE ke.updated_at > now() - interval '7 days')
|
COUNT(*) FILTER (WHERE ke.updated_at > now() - interval '7 days')
|
||||||
FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id
|
FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id
|
||||||
|
WHERE ke.deleted_at IS NULL
|
||||||
GROUP BY e.type`)
|
GROUP BY e.type`)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer srows.Close()
|
defer srows.Close()
|
||||||
@@ -128,15 +130,19 @@ func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var title, content, source string
|
var title, content, source, editedBy string
|
||||||
var tags []string
|
var tags []string
|
||||||
var updatedAt string
|
var updatedAt string
|
||||||
|
var revisions int
|
||||||
err = s.pool.QueryRow(ctx, `
|
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
|
FROM knowledge_entities ke
|
||||||
JOIN entities e ON e.id = ke.entity_id
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
WHERE e.slug = $1 OR e.id::text = $1`, idOrSlug).
|
WHERE (e.slug = $1 OR e.id::text = $1)
|
||||||
Scan(&title, &content, &source, &tags, &updatedAt)
|
AND ke.deleted_at IS NULL`, idOrSlug).
|
||||||
|
Scan(&title, &content, &source, &editedBy, &tags, &updatedAt, &revisions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblem(w, req, http.StatusNotFound, "no knowledge content for entity", "")
|
writeProblem(w, req, http.StatusNotFound, "no knowledge content for entity", "")
|
||||||
return
|
return
|
||||||
@@ -150,8 +156,10 @@ func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request)
|
|||||||
"title": title,
|
"title": title,
|
||||||
"content": content,
|
"content": content,
|
||||||
"source": source,
|
"source": source,
|
||||||
|
"edited_by": editedBy,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"updated_at": updatedAt,
|
"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 entities e ON e.id = ke.entity_id
|
||||||
JOIN entity_types et ON et.name = e.type
|
JOIN entity_types et ON et.name = e.type
|
||||||
WHERE ke.search @@ plainto_tsquery('english', $1)
|
WHERE ke.search @@ plainto_tsquery('english', $1)
|
||||||
|
AND ke.deleted_at IS NULL
|
||||||
ORDER BY rank DESC
|
ORDER BY rank DESC
|
||||||
LIMIT $2`,
|
LIMIT $2`,
|
||||||
q, limit)
|
q, limit)
|
||||||
@@ -232,6 +241,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
|
|||||||
WHERE target.slug = $1
|
WHERE target.slug = $1
|
||||||
AND r.valid_to IS NULL
|
AND r.valid_to IS NULL
|
||||||
AND r.type IN ('documents', 'about')
|
AND r.type IN ('documents', 'about')
|
||||||
|
AND ke.deleted_at IS NULL
|
||||||
UNION
|
UNION
|
||||||
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
|
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
|
||||||
FROM knowledge_entities ke
|
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
|
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
||||||
WHERE r.valid_to IS NULL
|
WHERE r.valid_to IS NULL
|
||||||
AND r.type = 'procedure-for'
|
AND r.type = 'procedure-for'
|
||||||
|
AND ke.deleted_at IS NULL
|
||||||
ORDER BY 2`,
|
ORDER BY 2`,
|
||||||
entitySlug)
|
entitySlug)
|
||||||
if err != nil {
|
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/events/stream — in OpenAPI but re-registered for SSE Flush()
|
||||||
// /api/v1/knowledge/recent — ad-hoc aggregation, no schema type yet
|
// /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/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/recent — recency-ordered, not paginated
|
||||||
// /api/v1/activity/session/{id} — session-scoped aggregation
|
// /api/v1/activity/session/{id} — session-scoped aggregation
|
||||||
// /api/v1/learning/timeline — derived view, no backing schema type
|
// /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.)
|
// (See "Non-OpenAPI routes" carve-out block above.)
|
||||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/content/{id}", s.serveKnowledgeContent)
|
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,
|
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
|
||||||
// unlike ListExecutions which sorts by target for pagination) and the
|
// unlike ListExecutions which sorts by target for pagination) and the
|
||||||
// per-session "what did this session do" digest.
|
// 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,
|
"useTabs": false,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
|
"semi": false,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
"style": "vega",
|
"style": "vega",
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"css": "src/app.css",
|
"css": "src/app.css",
|
||||||
"baseColor": "zinc"
|
"baseColor": "zinc"
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "$lib/components",
|
"components": "$lib/components",
|
||||||
"utils": "$lib/utils",
|
"utils": "$lib/utils",
|
||||||
"ui": "$lib/components/ui",
|
"ui": "$lib/components/ui",
|
||||||
"hooks": "$lib/hooks",
|
"hooks": "$lib/hooks",
|
||||||
"lib": "$lib"
|
"lib": "$lib"
|
||||||
},
|
},
|
||||||
"typescript": true,
|
"typescript": true,
|
||||||
"registry": "https://shadcn-svelte.com/registry"
|
"registry": "https://shadcn-svelte.com/registry"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
@@ -6,7 +6,10 @@
|
|||||||
<title>Oikos</title>
|
<title>Oikos</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
|
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
|
||||||
@@ -15,11 +18,20 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script>
|
<script>
|
||||||
(function(){try{var t=localStorage.getItem('oikos-theme');if(!t){t=window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'}
|
;(function () {
|
||||||
if(t==='dark')document.documentElement.classList.add('dark')}catch(e){}})()
|
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>
|
||||||
<script src="/wails/runtime.js"></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>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
11
web/package-lock.json
generated
11
web/package-lock.json
generated
@@ -18,7 +18,8 @@
|
|||||||
"uplot": "^1.6.32"
|
"uplot": "^1.6.32"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lucide/svelte": "^1.23.0",
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@lucide/svelte": "^1.25.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@tsconfig/svelte": "^5.0.0",
|
"@tsconfig/svelte": "^5.0.0",
|
||||||
@@ -870,7 +871,6 @@
|
|||||||
"integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==",
|
"integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
}
|
}
|
||||||
@@ -921,9 +921,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@lucide/svelte": {
|
"node_modules/@lucide/svelte": {
|
||||||
"version": "1.23.0",
|
"version": "1.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz",
|
||||||
"integrity": "sha512-3LQbKXx9vId6Nx4E2Nu2qwgJfdmr5+CVeVJbxe5cy+HcnCRd9QVVtZXqvgBYAV1OJrPmQAf9/3gJWLCpASC/Ng==",
|
"integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -1378,7 +1378,6 @@
|
|||||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lucide/svelte": "^1.23.0",
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@lucide/svelte": "^1.25.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@tsconfig/svelte": "^5.0.0",
|
"@tsconfig/svelte": "^5.0.0",
|
||||||
|
|||||||
122
web/src/app.css
122
web/src/app.css
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@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 {
|
@theme inline {
|
||||||
--font-sans: 'DM Sans', system-ui, sans-serif;
|
--font-sans: 'DM Sans', system-ui, sans-serif;
|
||||||
--font-mono: 'DM Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
--font-mono: 'DM Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
@@ -73,7 +84,7 @@
|
|||||||
--chart-3: oklch(0.5 0.08 30);
|
--chart-3: oklch(0.5 0.08 30);
|
||||||
--chart-4: oklch(0.6 0.06 90);
|
--chart-4: oklch(0.6 0.06 90);
|
||||||
--chart-5: oklch(0.4 0.04 45);
|
--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-foreground: oklch(0.18 0.03 45);
|
||||||
--sidebar-primary: oklch(0.55 0.14 45);
|
--sidebar-primary: oklch(0.55 0.14 45);
|
||||||
--sidebar-primary-foreground: oklch(0.95 0.02 55);
|
--sidebar-primary-foreground: oklch(0.95 0.02 55);
|
||||||
@@ -148,7 +159,6 @@
|
|||||||
--accent-orange: var(--warning);
|
--accent-orange: var(--warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Terminal-style block cursor — outside @layer so it overrides CodeMirror */
|
/* Terminal-style block cursor — outside @layer so it overrides CodeMirror */
|
||||||
.cm-cursor,
|
.cm-cursor,
|
||||||
.cm-cursor-primary {
|
.cm-cursor-primary {
|
||||||
@@ -170,7 +180,12 @@
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,17 +269,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
/* Frosted glass — same idea as the desktop's "What should Nomos do?"
|
background: var(--card);
|
||||||
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);
|
|
||||||
color: var(--card-foreground);
|
color: var(--card-foreground);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -328,3 +333,92 @@ a:hover {
|
|||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
cursor: col-resize;
|
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 ?? []
|
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`, {
|
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ answer })
|
body: JSON.stringify({ answer })
|
||||||
@@ -133,42 +137,45 @@ export function streamChat(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ message, session_id: sessionId ?? undefined }),
|
body: JSON.stringify({ message, session_id: sessionId ?? undefined }),
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
}).then(async (res) => {
|
})
|
||||||
if (!res.ok) {
|
.then(async (res) => {
|
||||||
onError(`HTTP ${res.status}`)
|
if (!res.ok) {
|
||||||
return
|
onError(`HTTP ${res.status}`)
|
||||||
}
|
return
|
||||||
const reader = res.body?.getReader()
|
}
|
||||||
if (!reader) {
|
const reader = res.body?.getReader()
|
||||||
onError('no response body')
|
if (!reader) {
|
||||||
return
|
onError('no response body')
|
||||||
}
|
return
|
||||||
const decoder = new TextDecoder()
|
}
|
||||||
let buffer = ''
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
if (done) break
|
if (done) break
|
||||||
buffer += decoder.decode(value, { stream: true })
|
buffer += decoder.decode(value, { stream: true })
|
||||||
const lines = buffer.split('\n')
|
const lines = buffer.split('\n')
|
||||||
buffer = lines.pop() ?? ''
|
buffer = lines.pop() ?? ''
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data: ')) {
|
if (line.startsWith('data: ')) {
|
||||||
try {
|
try {
|
||||||
const ev: ChatEvent = JSON.parse(line.slice(6))
|
const ev: ChatEvent = JSON.parse(line.slice(6))
|
||||||
onEvent(ev)
|
onEvent(ev)
|
||||||
} catch {
|
} catch {
|
||||||
// skip malformed
|
// skip malformed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}).catch((err) => {
|
.catch((err) => {
|
||||||
onError(err.message)
|
onError(err.message)
|
||||||
}).finally(() => {
|
})
|
||||||
onDone()
|
.finally(() => {
|
||||||
})
|
onDone()
|
||||||
|
})
|
||||||
|
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
@@ -291,7 +298,9 @@ export interface EventFilters {
|
|||||||
severity?: string
|
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()
|
const params = new URLSearchParams()
|
||||||
if (filters.type) params.set('type', filters.type)
|
if (filters.type) params.set('type', filters.type)
|
||||||
if (filters.severity) params.set('severity', filters.severity)
|
if (filters.severity) params.set('severity', filters.severity)
|
||||||
@@ -483,7 +492,9 @@ export interface Signal {
|
|||||||
last_seen_at: string
|
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()
|
const params = new URLSearchParams()
|
||||||
if (filters.state) params.set('state', filters.state)
|
if (filters.state) params.set('state', filters.state)
|
||||||
if (filters.severity) params.set('severity', filters.severity)
|
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()
|
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`, {
|
const res = await fetchWithAuth(`${API}/signals/${id}/mute`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ mute_until: muteUntil, note })
|
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
|
// reachable going forward from here), this hits a dedicated endpoint that
|
||||||
// matches on source_id OR target_id directly.
|
// matches on source_id OR target_id directly.
|
||||||
export async function fetchEntityRelations(id: string): Promise<Relationship[]> {
|
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 []
|
if (!res.ok) return []
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
return data.items ?? []
|
return data.items ?? []
|
||||||
@@ -658,8 +675,10 @@ export interface KnowledgeContent {
|
|||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
source: string
|
source: string
|
||||||
|
edited_by: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
revisions: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full markdown body for a document/investigation/runbook entity — distinct
|
// 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()
|
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 params = new URLSearchParams({ entity_id: entityId, limit: '50' })
|
||||||
const res = await fetchWithAuth(`${API}/events?${params}`)
|
const res = await fetchWithAuth(`${API}/events?${params}`)
|
||||||
if (!res.ok) return []
|
if (!res.ok) return []
|
||||||
@@ -718,13 +992,19 @@ export async function fetchEntityTasks(entity: Entity): Promise<EntityTask[]> {
|
|||||||
tasks.map(async (task): Promise<EntityTask | null> => {
|
tasks.map(async (task): Promise<EntityTask | null> => {
|
||||||
const g = await fetchGraph({ root: task.slug, depth: 1 })
|
const g = await fetchGraph({ root: task.slug, depth: 1 })
|
||||||
if (!g) return null
|
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 nodeTypeById = new Map(g.nodes.map((n) => [n.id, n.type]))
|
||||||
const idBySlug = new Map(g.nodes.map((n) => [n.slug, n.id]))
|
const idBySlug = new Map(g.nodes.map((n) => [n.slug, n.id]))
|
||||||
const executionCount = g.edges.filter((e) => {
|
const executionCount = g.edges.filter((e) => {
|
||||||
if (e.type !== 'involves') return false
|
if (e.type !== 'involves') return false
|
||||||
const targetId = idBySlug.get(e.target)
|
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
|
}).length
|
||||||
if (!involvesThisEntity && executionCount === 0) return null
|
if (!involvesThisEntity && executionCount === 0) return null
|
||||||
return { task, executionCount }
|
return { task, executionCount }
|
||||||
@@ -755,7 +1035,11 @@ export async function fetchChecksForTarget(targetSlug: string): Promise<Check[]>
|
|||||||
return data.items ?? []
|
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}`, {
|
const res = await fetchWithAuth(`${API}/checks/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'If-Match': `"${version}"` },
|
headers: { 'If-Match': `"${version}"` },
|
||||||
@@ -781,12 +1065,14 @@ export interface AgentActivity {
|
|||||||
correlation_id?: string | null
|
correlation_id?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAgentActivity(filters: {
|
export async function fetchAgentActivity(
|
||||||
agent_id?: string
|
filters: {
|
||||||
activity_type?: string
|
agent_id?: string
|
||||||
entity_id?: string
|
activity_type?: string
|
||||||
limit?: number
|
entity_id?: string
|
||||||
} = {}): Promise<AgentActivity[]> {
|
limit?: number
|
||||||
|
} = {}
|
||||||
|
): Promise<AgentActivity[]> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (filters.agent_id) params.set('agent_id', filters.agent_id)
|
if (filters.agent_id) params.set('agent_id', filters.agent_id)
|
||||||
if (filters.activity_type) params.set('activity_type', filters.activity_type)
|
if (filters.activity_type) params.set('activity_type', filters.activity_type)
|
||||||
@@ -821,14 +1107,16 @@ export interface AuditEntry {
|
|||||||
correlation_id?: string | null
|
correlation_id?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAudit(filters: {
|
export async function fetchAudit(
|
||||||
actor_type?: string
|
filters: {
|
||||||
actor_id?: string
|
actor_type?: string
|
||||||
entity_id?: string
|
actor_id?: string
|
||||||
action?: string
|
entity_id?: string
|
||||||
correlation_id?: string
|
action?: string
|
||||||
limit?: number
|
correlation_id?: string
|
||||||
} = {}): Promise<AuditEntry[]> {
|
limit?: number
|
||||||
|
} = {}
|
||||||
|
): Promise<AuditEntry[]> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (filters.actor_type) params.set('actor_type', filters.actor_type)
|
if (filters.actor_type) params.set('actor_type', filters.actor_type)
|
||||||
if (filters.actor_id) params.set('actor_id', filters.actor_id)
|
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"
|
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>
|
></textarea>
|
||||||
<p class="shrink-0 text-xs text-muted-foreground">
|
<p class="shrink-0 text-xs text-muted-foreground">
|
||||||
A demo installable app — uninstall it from the App Store to remove its
|
A demo installable app — uninstall it from the App Store to remove its icon and window. Its
|
||||||
icon and window. Its notes persist in localStorage under
|
notes persist in localStorage under
|
||||||
<code class="font-mono">{storageKey}</code>.
|
<code class="font-mono">{storageKey}</code>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,9 +23,14 @@ import type { Component } from 'svelte'
|
|||||||
import { writable, derived, get, type Readable } from 'svelte/store'
|
import { writable, derived, get, type Readable } from 'svelte/store'
|
||||||
import type { DashboardSummary } from '$lib/api'
|
import type { DashboardSummary } from '$lib/api'
|
||||||
import { openSignalCount } from '$lib/stores/context'
|
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 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 ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||||
import SirenIcon from '@lucide/svelte/icons/siren'
|
import SirenIcon from '@lucide/svelte/icons/siren'
|
||||||
import SearchIcon from '@lucide/svelte/icons/search'
|
import SearchIcon from '@lucide/svelte/icons/search'
|
||||||
@@ -88,9 +93,11 @@ export const builtinApps: AppDef[] = [
|
|||||||
source: 'builtin'
|
source: 'builtin'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// id stays 'kb' so persisted window geometry / desktop-icon position /
|
||||||
|
// the 'oikos-kb-view' preference survive the rename to "Fleet".
|
||||||
id: 'kb',
|
id: 'kb',
|
||||||
title: 'Knowledge Base',
|
title: 'Fleet',
|
||||||
icon: DatabaseIcon,
|
icon: BoxesIcon,
|
||||||
component: () => import('../pages/KnowledgeBase.svelte'),
|
component: () => import('../pages/KnowledgeBase.svelte'),
|
||||||
width: 1000,
|
width: 1000,
|
||||||
height: 700,
|
height: 700,
|
||||||
@@ -235,8 +242,9 @@ export const apps: Readable<AppDef[]> = derived(installedIds, (ids) => {
|
|||||||
return [...builtinApps, ...installed]
|
return [...builtinApps, ...installed]
|
||||||
})
|
})
|
||||||
|
|
||||||
export const appById: Readable<Map<string, AppDef>> = derived(apps, (list) =>
|
export const appById: Readable<Map<string, AppDef>> = derived(
|
||||||
new Map(list.map((a) => [a.id, a]))
|
apps,
|
||||||
|
(list) => new Map(list.map((a) => [a.id, a]))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Install/uninstall. Idempotent — installing an already-installed app or
|
// 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 type { Readable } from 'svelte/store'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import { Textarea } from '$lib/components/ui/textarea'
|
import { Textarea } from '$lib/components/ui/textarea'
|
||||||
import Spinner from './Spinner.svelte'
|
import AgentTrace from './AgentTrace.svelte'
|
||||||
import ToolCallCard from './ToolCallCard.svelte'
|
|
||||||
import OperatorQuestion from './OperatorQuestion.svelte'
|
import OperatorQuestion from './OperatorQuestion.svelte'
|
||||||
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
|
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 RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
|
||||||
import SquareIcon from '@lucide/svelte/icons/square'
|
import SquareIcon from '@lucide/svelte/icons/square'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
@@ -63,10 +60,16 @@
|
|||||||
let wasStreaming = $state(false)
|
let wasStreaming = $state(false)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (streaming) { indicatorDone = false; wasStreaming = true }
|
if (streaming) {
|
||||||
|
indicatorDone = false
|
||||||
|
wasStreaming = true
|
||||||
|
}
|
||||||
if (!streaming && wasStreaming) {
|
if (!streaming && wasStreaming) {
|
||||||
indicatorDone = true
|
indicatorDone = true
|
||||||
const t = setTimeout(() => { indicatorDone = false; wasStreaming = false }, 3000)
|
const t = setTimeout(() => {
|
||||||
|
indicatorDone = false
|
||||||
|
wasStreaming = false
|
||||||
|
}, 3000)
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -94,7 +97,10 @@
|
|||||||
const lineHeight = parseFloat(taCs.lineHeight)
|
const lineHeight = parseFloat(taCs.lineHeight)
|
||||||
if (!Number.isFinite(lineHeight)) return
|
if (!Number.isFinite(lineHeight)) return
|
||||||
const taBoxY =
|
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
|
// 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
|
// 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.
|
// squeezed below one line once the pane is dragged down to it.
|
||||||
@@ -147,7 +153,9 @@
|
|||||||
}
|
}
|
||||||
renderer.table = function (token) {
|
renderer.table = function (token) {
|
||||||
const header = token.header.map((c: { text: string }) => `<th>${c.text}</th>`).join('')
|
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 `<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)
|
return DOMPurify.sanitize(marked.parse(text, { async: false, renderer }) as string)
|
||||||
@@ -184,125 +192,174 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full min-h-0 min-w-0 flex-col" bind:clientHeight={threadHeight}>
|
<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">
|
<Pane class="flex flex-col">
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
<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">
|
<div class="mx-auto flex min-h-full max-w-3xl flex-col gap-5 p-4">
|
||||||
{#if messages.length === 0}
|
{#if messages.length === 0}
|
||||||
<div class="flex flex-1 flex-col items-center justify-center gap-6 text-center">
|
<div class="flex flex-1 flex-col items-center justify-center gap-6 text-center">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold">Nomos</h2>
|
<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>
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
</div>
|
Your resident operator. Ask about the fleet, or tell it to act.
|
||||||
{#if suggestions.length}
|
</p>
|
||||||
<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>
|
</div>
|
||||||
{#if msg.text}
|
{#if suggestions.length}
|
||||||
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
|
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
{#each suggestions as q}
|
||||||
{@html render(msg.text)}
|
<Button
|
||||||
{#if idx === messages.length - 1 && streaming}
|
variant="outline"
|
||||||
<span class="stream-cursor" aria-hidden="true"></span>
|
size="sm"
|
||||||
{/if}
|
class="h-auto justify-start whitespace-normal py-2 text-left text-xs"
|
||||||
</div>
|
onclick={() => ask(q)}
|
||||||
{/if}
|
>
|
||||||
{#if msg.tools.length > 0}
|
{q}
|
||||||
<div class="flex flex-col gap-1.5">
|
</Button>
|
||||||
{#each msg.tools as tool (tool.id)}
|
|
||||||
<ToolCallCard {tool} />
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if idx === messages.length - 1 && msg.text === '' && (streaming || indicatorDone || error)}
|
</div>
|
||||||
<div class="flex items-center gap-2 py-1 text-xs {error ? 'text-destructive' : indicatorDone ? 'text-primary' : 'text-muted-foreground'}">
|
{/if}
|
||||||
{#if error}
|
|
||||||
<XIcon class="size-3 shrink-0" />
|
{#each messages as msg, idx (msg.id)}
|
||||||
{:else if indicatorDone}
|
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
||||||
<CheckIcon class="size-3 shrink-0" />
|
{#if msg.role === 'user'}
|
||||||
{:else}
|
<div class="flex items-baseline gap-2 px-1">
|
||||||
<Spinner class="size-3 shrink-0 text-primary" />
|
<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}
|
{/if}
|
||||||
<span>{indicatorLabel}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if question}
|
||||||
|
<OperatorQuestion {sessionId} {question} />
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
{/each}
|
{/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>
|
||||||
|
|
||||||
<Pane bind:size={inputSize} minSize={inputMinSize} maxSize={45} class="flex flex-col">
|
<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
|
<form
|
||||||
class="relative mx-auto flex h-full w-full max-w-3xl"
|
class="relative mx-auto flex h-full w-full max-w-3xl"
|
||||||
onsubmit={(e) => {
|
onsubmit={(e) => {
|
||||||
@@ -361,50 +418,36 @@
|
|||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Prose overrides */
|
/* Prose overrides — deltas on top of the shared .markdown-body base
|
||||||
.prose-chat :global(p) {
|
(app.css) only. The template applies both classes together
|
||||||
margin: 0 0 0.5rem;
|
(class="markdown-body prose-chat ..."); everything below either adds a
|
||||||
}
|
look .markdown-body doesn't have (li::marker, the pre/blockquote
|
||||||
.prose-chat :global(p:last-child) {
|
::before ornaments, hr, strong, the table-wrapper, the code-copy
|
||||||
margin-bottom: 0;
|
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
|
||||||
.prose-chat :global(ul),
|
value is actually overridden, the selector is
|
||||||
.prose-chat :global(ol) {
|
`.markdown-body.prose-chat` rather than `.prose-chat` alone —
|
||||||
margin: 0 0 0.5rem;
|
:global() selectors from two different <style> blocks land in the same
|
||||||
padding-left: 1.25rem;
|
stylesheet with no scoping to arbitrate between them, so equal
|
||||||
}
|
specificity would leave the winner to injection order (unreliable
|
||||||
.prose-chat :global(ul) {
|
across dev/build). The two-class selector's higher specificity wins
|
||||||
list-style-type: disc;
|
deterministically regardless. */
|
||||||
}
|
|
||||||
.prose-chat :global(ol) {
|
|
||||||
list-style-type: decimal;
|
|
||||||
}
|
|
||||||
.prose-chat :global(li) {
|
.prose-chat :global(li) {
|
||||||
margin-bottom: 0.125rem;
|
|
||||||
padding-left: 0.25rem;
|
padding-left: 0.25rem;
|
||||||
}
|
}
|
||||||
.prose-chat :global(li::marker) {
|
.prose-chat :global(li::marker) {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-chat :global(code) {
|
.markdown-body.prose-chat :global(code) {
|
||||||
background: var(--muted);
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.15em 0.4em;
|
padding: 0.15em 0.4em;
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.85em;
|
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-chat :global(pre) {
|
.markdown-body.prose-chat :global(pre) {
|
||||||
background: var(--muted);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.75rem 0.875rem;
|
padding: 0.75rem 0.875rem;
|
||||||
overflow-x: auto;
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.prose-chat :global(pre)::before {
|
.prose-chat :global(pre)::before {
|
||||||
@@ -418,34 +461,28 @@
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
.prose-chat :global(pre code) {
|
.prose-chat :global(pre code) {
|
||||||
background: none;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section headings — serif (Inknut) with a short accent rule. Extra top
|
/* Section headings — serif (Inknut) with a short accent rule. Extra top
|
||||||
margin separates sections; the first heading in a message doesn't. */
|
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-size: 1.15em;
|
||||||
font-weight: 600;
|
|
||||||
margin: 1.15rem 0 0.4rem;
|
margin: 1.15rem 0 0.4rem;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
.prose-chat :global(h2) {
|
.markdown-body.prose-chat :global(h2) {
|
||||||
font-size: 1.08em;
|
font-size: 1.08em;
|
||||||
font-weight: 600;
|
|
||||||
margin: 1.15rem 0 0.4rem;
|
margin: 1.15rem 0 0.4rem;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
.prose-chat :global(h3) {
|
.markdown-body.prose-chat :global(h3) {
|
||||||
font-size: 1.02em;
|
font-size: 1.02em;
|
||||||
font-weight: 600;
|
|
||||||
margin: 1.15rem 0 0.4rem;
|
margin: 1.15rem 0 0.4rem;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -469,11 +506,6 @@
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-chat :global(table) {
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
.prose-chat :global(.table-wrapper) {
|
.prose-chat :global(.table-wrapper) {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
@@ -485,18 +517,13 @@
|
|||||||
background: var(--muted);
|
background: var(--muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.prose-chat :global(th),
|
.markdown-body.prose-chat :global(th),
|
||||||
.prose-chat :global(td) {
|
.markdown-body.prose-chat :global(td) {
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 0.3rem 0.6rem;
|
padding: 0.3rem 0.6rem;
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-chat :global(blockquote) {
|
.markdown-body.prose-chat :global(blockquote) {
|
||||||
border-left: 3px solid var(--primary);
|
border-left: 3px solid var(--primary);
|
||||||
padding-left: 0.75rem;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -516,7 +543,13 @@
|
|||||||
border: none;
|
border: none;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
margin: 0.75rem 0;
|
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
|
/* Bold is emphasis, not color — dark weight reads cleanly and lets the
|
||||||
@@ -526,8 +559,7 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-chat :global(a) {
|
.markdown-body.prose-chat :global(a) {
|
||||||
color: var(--primary);
|
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-decoration-style: dotted;
|
text-decoration-style: dotted;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
@@ -561,7 +593,9 @@
|
|||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s, color 0.15s;
|
transition:
|
||||||
|
opacity 0.15s,
|
||||||
|
color 0.15s;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -588,7 +622,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes cursor-blink {
|
@keyframes cursor-blink {
|
||||||
0%, 100% { opacity: 0.75; }
|
0%,
|
||||||
50% { opacity: 0; }
|
100% {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
let canvas = $state<HTMLCanvasElement | null>(null)
|
let canvas = $state<HTMLCanvasElement | null>(null)
|
||||||
let particles: Particle[] = []
|
let particles: Particle[] = []
|
||||||
let mouse = { x: -500, y: -500 }
|
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
|
let timer: ReturnType<typeof setTimeout> | 0 = 0
|
||||||
|
|
||||||
function spawn() {
|
function spawn() {
|
||||||
@@ -73,15 +75,15 @@
|
|||||||
// update + draw particles
|
// update + draw particles
|
||||||
for (const p of particles) {
|
for (const p of particles) {
|
||||||
// autonomous drift
|
// autonomous drift
|
||||||
p.vx += (Math.sin(t * 0.4 + 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
|
p.vy += Math.cos(t * 0.35 + p.phase) * 0.003 * 0.15
|
||||||
|
|
||||||
// mouse interaction
|
// mouse interaction
|
||||||
const dx = p.x - mouse.x
|
const dx = p.x - mouse.x
|
||||||
const dy = p.y - mouse.y
|
const dy = p.y - mouse.y
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||||
if (dist < MOUSE_RADIUS && dist > 0) {
|
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.vx += (dx / dist) * force * 0.6
|
||||||
p.vy += (dy / dist) * force * 0.6
|
p.vy += (dy / dist) * force * 0.6
|
||||||
}
|
}
|
||||||
@@ -104,9 +106,7 @@
|
|||||||
|
|
||||||
// pulse brightness
|
// pulse brightness
|
||||||
const alpha = p.pulse * (0.35 + 0.15 * Math.sin(t * 1.2 + p.phase))
|
const alpha = p.pulse * (0.35 + 0.15 * Math.sin(t * 1.2 + p.phase))
|
||||||
ctx.fillStyle = dark
|
ctx.fillStyle = dark ? `rgba(140,175,230,${alpha})` : `rgba(60,90,140,${alpha})`
|
||||||
? `rgba(140,175,230,${alpha})`
|
|
||||||
: `rgba(60,90,140,${alpha})`
|
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
|
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
@@ -123,9 +123,7 @@
|
|||||||
const dist = dx * dx + dy * dy
|
const dist = dx * dx + dy * dy
|
||||||
if (dist < CONNECT_DIST * CONNECT_DIST) {
|
if (dist < CONNECT_DIST * CONNECT_DIST) {
|
||||||
const alpha = (1 - Math.sqrt(dist) / CONNECT_DIST) * 0.18
|
const alpha = (1 - Math.sqrt(dist) / CONNECT_DIST) * 0.18
|
||||||
ctx.strokeStyle = dark
|
ctx.strokeStyle = dark ? `rgba(140,175,230,${alpha})` : `rgba(60,90,140,${alpha})`
|
||||||
? `rgba(140,175,230,${alpha})`
|
|
||||||
: `rgba(60,90,140,${alpha})`
|
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.moveTo(a.x, a.y)
|
ctx.moveTo(a.x, a.y)
|
||||||
ctx.lineTo(b.x, b.y)
|
ctx.lineTo(b.x, b.y)
|
||||||
@@ -135,7 +133,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// radial scrim to keep center legible
|
// 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 scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
|
||||||
const base = dark ? '13,17,23' : '255,255,255'
|
const base = dark ? '13,17,23' : '255,255,255'
|
||||||
scrim.addColorStop(0, `rgba(${base},0.72)`)
|
scrim.addColorStop(0, `rgba(${base},0.72)`)
|
||||||
|
|||||||
@@ -22,14 +22,20 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Collapsible.Root bind:open class="rounded-md border bg-card">
|
<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>
|
<span class="text-xs font-medium">{title}{count !== undefined ? ` (${count})` : ''}</span>
|
||||||
<ChevronDownIcon
|
<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"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</Collapsible.Trigger>
|
</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">
|
<div class="border-t px-2 py-1.5">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<tr>
|
<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}
|
{message}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -62,7 +62,9 @@
|
|||||||
// (source OR target = entity, both directions), so a plain split by which
|
// (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
|
// side matches is enough — no risk of an unrelated sibling-to-sibling edge
|
||||||
// sneaking into either group.
|
// 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(
|
const incomingRelations = $derived(
|
||||||
entity ? relations.filter((r) => r.target === entity!.slug && r.source !== entity!.slug) : []
|
entity ? relations.filter((r) => r.target === entity!.slug && r.source !== entity!.slug) : []
|
||||||
)
|
)
|
||||||
@@ -203,13 +205,23 @@
|
|||||||
body?: string
|
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[] {
|
function isChangelog(value: unknown): value is ChangelogEntry[] {
|
||||||
return (
|
return (
|
||||||
Array.isArray(value) &&
|
Array.isArray(value) &&
|
||||||
value.length > 0 &&
|
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>
|
||||||
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
||||||
<dt class="shrink-0 text-muted-foreground">State</dt>
|
<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>
|
||||||
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
||||||
<dt class="shrink-0 text-muted-foreground">Health</dt>
|
<dt class="shrink-0 text-muted-foreground">Health</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{#if entity.health}
|
{#if entity.health}
|
||||||
<span class="flex items-center gap-1.5" title="checked {relativeTime(entity.last_check_at)}">
|
<span
|
||||||
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"></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)}
|
{entity.health} · checked {relativeTime(entity.last_check_at)}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -286,7 +306,11 @@
|
|||||||
<dt class="shrink-0 text-muted-foreground">Created</dt>
|
<dt class="shrink-0 text-muted-foreground">Created</dt>
|
||||||
<dd title={entity.created_at}>{relativeTime(entity.created_at)}</dd>
|
<dd title={entity.created_at}>{relativeTime(entity.created_at)}</dd>
|
||||||
</div>
|
</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>
|
<dt class="shrink-0 text-muted-foreground">Updated</dt>
|
||||||
<dd title={entity.updated_at}>{relativeTime(entity.updated_at)}</dd>
|
<dd title={entity.updated_at}>{relativeTime(entity.updated_at)}</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,7 +337,9 @@
|
|||||||
onclick={() => toggleCheck(check)}
|
onclick={() => toggleCheck(check)}
|
||||||
title={check.enabled ? 'Click to disable' : 'Click to enable'}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -324,8 +350,10 @@
|
|||||||
|
|
||||||
{#snippet contentContent()}
|
{#snippet contentContent()}
|
||||||
{#if ownContent}
|
{#if ownContent}
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
<div class="markdown-body max-w-none text-xs">
|
||||||
<div class="prose-chat max-w-none text-xs">{@html renderMarkdown(ownContent.content)}</div>
|
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||||
|
{@html renderMarkdown(ownContent.content)}
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-xs text-muted-foreground">No content.</p>
|
<p class="text-xs text-muted-foreground">No content.</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -339,7 +367,9 @@
|
|||||||
{#if row.kind === 'long-text'}
|
{#if row.kind === 'long-text'}
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
<p class="font-mono text-muted-foreground">{row.key}</p>
|
<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>
|
</div>
|
||||||
{:else if row.kind === 'changelog'}
|
{:else if row.kind === 'changelog'}
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
@@ -348,10 +378,16 @@
|
|||||||
{#each row.value as entry}
|
{#each row.value as entry}
|
||||||
<div class="rounded-sm border-l-2 border-muted-foreground/30 pl-1.5">
|
<div class="rounded-sm border-l-2 border-muted-foreground/30 pl-1.5">
|
||||||
<div class="flex items-baseline gap-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}
|
{#if entry.title}<span class="font-medium">{entry.title}</span>{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -373,7 +409,12 @@
|
|||||||
<dt class="shrink-0 font-mono text-muted-foreground">{row.key}</dt>
|
<dt class="shrink-0 font-mono text-muted-foreground">{row.key}</dt>
|
||||||
<dd class="min-w-0 flex-1 break-words text-right">
|
<dd class="min-w-0 flex-1 break-words text-right">
|
||||||
{#if row.value !== null && typeof row.value === 'object'}
|
{#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}
|
{:else}
|
||||||
{String(row.value)}
|
{String(row.value)}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -390,13 +431,27 @@
|
|||||||
{#snippet relationRow(rel: Relationship)}
|
{#snippet relationRow(rel: Relationship)}
|
||||||
<div class="flex min-w-0 items-center gap-1 font-mono text-xs">
|
<div class="flex min-w-0 items-center gap-1 font-mono text-xs">
|
||||||
{#if onSelectEntity}
|
{#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>
|
<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}
|
{: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="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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -408,7 +463,11 @@
|
|||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
{#if outgoingRelations.length}
|
{#if outgoingRelations.length}
|
||||||
<div>
|
<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">
|
<div class="flex flex-col gap-1">
|
||||||
{#each outgoingRelations as rel}
|
{#each outgoingRelations as rel}
|
||||||
{@render relationRow(rel)}
|
{@render relationRow(rel)}
|
||||||
@@ -418,7 +477,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if incomingRelations.length}
|
{#if incomingRelations.length}
|
||||||
<div>
|
<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">
|
<div class="flex flex-col gap-1">
|
||||||
{#each incomingRelations as rel}
|
{#each incomingRelations as rel}
|
||||||
{@render relationRow(rel)}
|
{@render relationRow(rel)}
|
||||||
@@ -492,21 +555,32 @@
|
|||||||
{#snippet tasksContent()}
|
{#snippet tasksContent()}
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
{#each tasks as { task, executionCount } (task.id)}
|
{#each tasks as { task, executionCount } (task.id)}
|
||||||
{@const title = typeof task.attributes?.title === 'string' ? task.attributes.title : task.name}
|
{@const title =
|
||||||
{@const outcome = typeof task.attributes?.outcome === 'string' ? task.attributes.outcome : undefined}
|
typeof task.attributes?.title === 'string' ? task.attributes.title : task.name}
|
||||||
<div class="flex items-center justify-between gap-2 border-b pb-1 text-xs last:border-0 last:pb-0">
|
{@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}
|
{#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}
|
{title}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
<div class="flex shrink-0 items-center gap-1">
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
{#if outcome}
|
{#if outcome}
|
||||||
<Badge variant={outcome === 'success' ? 'default' : 'destructive'}>{outcome}</Badge>
|
<Badge variant={outcome === 'success' ? 'default' : 'destructive'}>{outcome}</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
<Badge variant="outline">{executionCount} action{executionCount === 1 ? '' : 's'}</Badge>
|
<Badge variant="outline"
|
||||||
|
>{executionCount} action{executionCount === 1 ? '' : 's'}</Badge
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -545,8 +619,12 @@
|
|||||||
{#each agentActivity as activity (activity.id)}
|
{#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 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">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<span class="font-mono text-muted-foreground">{new Date(activity.ts).toLocaleString()}</span>
|
<span class="font-mono text-muted-foreground"
|
||||||
<Badge variant={activity.success === false ? 'destructive' : 'outline'}>{activity.activity_type}</Badge>
|
>{new Date(activity.ts).toLocaleString()}</span
|
||||||
|
>
|
||||||
|
<Badge variant={activity.success === false ? 'destructive' : 'outline'}
|
||||||
|
>{activity.activity_type}</Badge
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<span class="truncate text-muted-foreground"
|
<span class="truncate text-muted-foreground"
|
||||||
>{activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''}</span
|
>{activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''}</span
|
||||||
@@ -563,10 +641,14 @@
|
|||||||
{#each auditEntries as entry (entry.id)}
|
{#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 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">
|
<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>
|
<Badge variant="outline">{entry.actor_type}</Badge>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-xs text-muted-foreground">No audit entries.</p>
|
<p class="text-xs text-muted-foreground">No audit entries.</p>
|
||||||
@@ -575,17 +657,34 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{@const sections = [
|
{@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: 'details', title: 'Details', count: 1, content: detailsContent },
|
||||||
{ key: 'monitoring', title: 'Monitoring', count: checks.length, content: monitoringContent },
|
{ 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: 'metrics', title: 'Metrics', count: metrics.length, content: metricsContent },
|
||||||
{ key: 'signals', title: 'Signals', count: signals.length, content: signalsContent },
|
{ key: 'signals', title: 'Signals', count: signals.length, content: signalsContent },
|
||||||
{ key: 'tasks', title: 'Tasks', count: tasks.length, content: tasksContent },
|
{ key: 'tasks', title: 'Tasks', count: tasks.length, content: tasksContent },
|
||||||
{ key: 'knowledge', title: 'Knowledge', count: knowledge.length, content: knowledgeContent },
|
{ key: 'knowledge', title: 'Knowledge', count: knowledge.length, content: knowledgeContent },
|
||||||
{ key: 'events', title: 'Recent events', count: events.length, content: eventsContent },
|
{ 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 }
|
{ key: 'audit', title: 'Audit trail', count: auditEntries.length, content: auditContent }
|
||||||
].sort((a, b) => (b.count > 0 ? 1 : 0) - (a.count > 0 ? 1 : 0))}
|
].sort((a, b) => (b.count > 0 ? 1 : 0) - (a.count > 0 ? 1 : 0))}
|
||||||
|
|
||||||
@@ -596,67 +695,3 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 }
|
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 {
|
function sortValue(entity: Entity, key: SortKey): string | number {
|
||||||
if (key === 'health') return entity.health ? (healthRank[entity.health] ?? -1) : -1
|
if (key === 'health') return entity.health ? (healthRank[entity.health] ?? -1) : -1
|
||||||
@@ -131,7 +137,9 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#snippet row(entity: Entity, level: number, ancestors: Set<string>)}
|
{#snippet row(entity: Entity, level: number, ancestors: Set<string>)}
|
||||||
{@const ancestorsWithSelf = new Set(ancestors).add(entity.slug)}
|
{@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
|
<Table.Row
|
||||||
class="cursor-pointer {entity.slug === selectedSlug ? 'bg-muted' : ''}"
|
class="cursor-pointer {entity.slug === selectedSlug ? 'bg-muted' : ''}"
|
||||||
role="row"
|
role="row"
|
||||||
@@ -139,7 +147,12 @@
|
|||||||
aria-expanded={children.length > 0 ? !collapsedNodes.has(entity.slug) : undefined}
|
aria-expanded={children.length > 0 ? !collapsedNodes.has(entity.slug) : undefined}
|
||||||
tabindex={0}
|
tabindex={0}
|
||||||
onclick={() => onSelect(entity.slug)}
|
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">
|
<Table.Cell class="font-mono text-xs">
|
||||||
<span class="flex items-center gap-1" style="padding-left: {(level - 1) * 1.25}rem">
|
<span class="flex items-center gap-1" style="padding-left: {(level - 1) * 1.25}rem">
|
||||||
@@ -149,7 +162,9 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="rounded text-muted-foreground hover:text-foreground"
|
class="rounded text-muted-foreground hover:text-foreground"
|
||||||
onclick={(e) => toggleNode(entity.slug, e)}
|
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)}
|
{#if collapsedNodes.has(entity.slug)}
|
||||||
<ChevronRightIcon class="size-3.5" />
|
<ChevronRightIcon class="size-3.5" />
|
||||||
@@ -186,23 +201,48 @@
|
|||||||
<Table.Row>
|
<Table.Row>
|
||||||
{@const ssSlug = getSortState('slug')}
|
{@const ssSlug = getSortState('slug')}
|
||||||
<Table.Head>
|
<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>
|
</Table.Head>
|
||||||
{@const ssType = getSortState('type')}
|
{@const ssType = getSortState('type')}
|
||||||
<Table.Head>
|
<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>
|
</Table.Head>
|
||||||
{@const ssName = getSortState('name')}
|
{@const ssName = getSortState('name')}
|
||||||
<Table.Head>
|
<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>
|
</Table.Head>
|
||||||
{@const ssState = getSortState('state')}
|
{@const ssState = getSortState('state')}
|
||||||
<Table.Head>
|
<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>
|
</Table.Head>
|
||||||
{@const ssHealth = getSortState('health')}
|
{@const ssHealth = getSortState('health')}
|
||||||
<Table.Head>
|
<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.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
|
|||||||
@@ -8,14 +8,22 @@
|
|||||||
children
|
children
|
||||||
}: {
|
}: {
|
||||||
value?: string
|
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
|
class?: string
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
children?: any
|
children?: any
|
||||||
} = $props()
|
} = $props()
|
||||||
</script>
|
</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>
|
<Tabs.List>
|
||||||
{#each tabs as tab}
|
{#each tabs as tab}
|
||||||
<Tabs.Trigger value={tab.value}>
|
<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'
|
import CircleHelpIcon from '@lucide/svelte/icons/circle-help'
|
||||||
|
|
||||||
// Prop-driven (not store-imported) — see SessionGraph.svelte for why.
|
// 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 freeText = $state('')
|
||||||
let submitting = $state(false)
|
let submitting = $state(false)
|
||||||
@@ -48,7 +49,13 @@
|
|||||||
{#if q.context.options?.length}
|
{#if q.context.options?.length}
|
||||||
<div class="ml-6 flex flex-wrap gap-1.5">
|
<div class="ml-6 flex flex-wrap gap-1.5">
|
||||||
{#each q.context.options as opt}
|
{#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}
|
{opt}
|
||||||
</Button>
|
</Button>
|
||||||
{/each}
|
{/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
|
Send
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,15 @@
|
|||||||
// these can be open (and independently live) at once.
|
// these can be open (and independently live) at once.
|
||||||
import { onDestroy, onMount } from 'svelte'
|
import { onDestroy, onMount } from 'svelte'
|
||||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
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 { activityLogFor } from '$lib/stores/activity'
|
||||||
import { workspaceFor, startSessionWorkspace } from '$lib/stores/workspace'
|
import { workspaceFor, startSessionWorkspace } from '$lib/stores/workspace'
|
||||||
import ChatThread from '$lib/components/ChatThread.svelte'
|
import ChatThread from '$lib/components/ChatThread.svelte'
|
||||||
@@ -77,7 +85,9 @@
|
|||||||
|
|
||||||
<div class="flex h-full min-h-0">
|
<div class="flex h-full min-h-0">
|
||||||
{#if loading}
|
{#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}
|
{:else if $chatNotFound}
|
||||||
<div class="flex flex-1 flex-col items-center justify-center gap-1 p-6 text-center">
|
<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>
|
<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
|
// 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
|
// page's global "current session" data or a floating task window's own
|
||||||
// per-session data — see TaskContextPanel.svelte, which supplies both.
|
// 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
|
// 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
|
// 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))
|
const curSlugs = new Set(current.map((n) => n.slug))
|
||||||
|
|
||||||
let changed = desiredSlugs.size !== curSlugs.size
|
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
|
if (!changed) return
|
||||||
|
|
||||||
const bySlug = new Map(current.map((n) => [n.slug, n]))
|
const bySlug = new Map(current.map((n) => [n.slug, n]))
|
||||||
@@ -178,10 +187,19 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
sim = forceSimulation(nodes)
|
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('charge', forceManyBody().strength(-150).distanceMax(240))
|
||||||
.force('center', forceCenter(cw / 2, ch / 2))
|
.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('x', forceX(cw / 2).strength(0.06))
|
||||||
.force('y', forceY(ch / 2).strength(0.06))
|
.force('y', forceY(ch / 2).strength(0.06))
|
||||||
.velocityDecay(0.34)
|
.velocityDecay(0.34)
|
||||||
@@ -232,7 +250,9 @@
|
|||||||
unknown: 'var(--muted-foreground)'
|
unknown: 'var(--muted-foreground)'
|
||||||
}
|
}
|
||||||
function nodeColor(n: Node): string {
|
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 {
|
function nodeRadius(n: Node): number {
|
||||||
return 6 + Math.min(Math.sqrt(n.degree) * 1.5, 6)
|
return 6 + Math.min(Math.sqrt(n.degree) * 1.5, 6)
|
||||||
@@ -306,10 +326,17 @@
|
|||||||
const selectedRelations = $derived(
|
const selectedRelations = $derived(
|
||||||
selected
|
selected
|
||||||
? links
|
? 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) => {
|
.map((l) => {
|
||||||
const outgoing = endpointSlug(l.source) === selected!.slug
|
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">
|
<aside class="flex h-full min-h-0 flex-col bg-card/40">
|
||||||
{#if nowTouching}
|
{#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>
|
<span class="size-1.5 animate-pulse rounded-full bg-primary"></span>
|
||||||
Now touching <code class="font-mono">{nowTouching.slug}</code>
|
Now touching <code class="font-mono">{nowTouching.slug}</code>
|
||||||
</div>
|
</div>
|
||||||
@@ -325,22 +354,85 @@
|
|||||||
|
|
||||||
<div bind:this={container} class="relative min-h-0 flex-1 overflow-hidden">
|
<div bind:this={container} class="relative min-h-0 flex-1 overflow-hidden">
|
||||||
{#if nodes.length === 0}
|
{#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">
|
<svg viewBox="0 0 120 120" class="size-24 text-muted-foreground/40" fill="none">
|
||||||
<circle cx="60" cy="60" r="6" fill="currentColor">
|
<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>
|
</circle>
|
||||||
<g stroke="currentColor" stroke-width="1" opacity="0.5">
|
<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="26" y2="34"
|
||||||
<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>
|
><animate
|
||||||
<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>
|
attributeName="opacity"
|
||||||
<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>
|
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>
|
||||||
<g fill="currentColor">
|
<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="26" cy="34" r="3.5"
|
||||||
<circle cx="96" cy="40" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3.4s" repeatCount="indefinite" /></circle>
|
><animate
|
||||||
<circle cx="34" cy="92" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="2.8s" repeatCount="indefinite" /></circle>
|
attributeName="opacity"
|
||||||
<circle cx="92" cy="90" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3.1s" repeatCount="indefinite" /></circle>
|
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>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="max-w-[16rem] text-xs leading-relaxed text-muted-foreground">
|
<p class="max-w-[16rem] text-xs leading-relaxed text-muted-foreground">
|
||||||
@@ -394,7 +486,8 @@
|
|||||||
{#if node.x != null && node.y != null}
|
{#if node.x != null && node.y != null}
|
||||||
{@const r = nodeRadius(node)}
|
{@const r = nodeRadius(node)}
|
||||||
{@const isSel = selected?.slug === node.slug}
|
{@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 isTouched = node.slug in touchedBySlug}
|
||||||
{@const diff = diffBySlug[node.slug]}
|
{@const diff = diffBySlug[node.slug]}
|
||||||
<g
|
<g
|
||||||
@@ -410,12 +503,33 @@
|
|||||||
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
||||||
{/if}
|
{/if}
|
||||||
{#if isTouched}
|
{#if isTouched}
|
||||||
<circle r={r + 4} fill="none" stroke="var(--primary)" stroke-width="1.5" opacity="0.8">
|
<circle
|
||||||
<animate attributeName="r" values="{r + 3};{r + 8};{r + 3}" dur="1.6s" repeatCount="indefinite" />
|
r={r + 4}
|
||||||
<animate attributeName="opacity" values="0.8;0.1;0.8" dur="1.6s" repeatCount="indefinite" />
|
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>
|
</circle>
|
||||||
{/if}
|
{/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
|
<text
|
||||||
y={r + 10}
|
y={r + 10}
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
|
|||||||
@@ -12,7 +12,11 @@
|
|||||||
<svg viewBox="0 0 24 24" class={className} fill="none" aria-hidden="true">
|
<svg viewBox="0 0 24 24" class={className} fill="none" aria-hidden="true">
|
||||||
{#each Array.from({ length: TICKS }) as _, i (i)}
|
{#each Array.from({ length: TICKS }) as _, i (i)}
|
||||||
<rect
|
<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"
|
fill="currentColor"
|
||||||
opacity="0.15"
|
opacity="0.15"
|
||||||
transform="rotate({i * (360 / TICKS)} 12 12)"
|
transform="rotate({i * (360 / TICKS)} 12 12)"
|
||||||
|
|||||||
@@ -13,15 +13,18 @@
|
|||||||
class?: string
|
class?: string
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
const variantMap: Record<StatusKind, Record<string, 'default' | 'secondary' | 'destructive' | 'outline'>> = {
|
const variantMap: Record<
|
||||||
|
StatusKind,
|
||||||
|
Record<string, 'default' | 'secondary' | 'destructive' | 'outline'>
|
||||||
|
> = {
|
||||||
risk: {
|
risk: {
|
||||||
destructive: 'destructive',
|
destructive: 'destructive',
|
||||||
config_mutation: 'secondary',
|
config_mutation: 'secondary'
|
||||||
},
|
},
|
||||||
severity: {
|
severity: {
|
||||||
critical: 'destructive',
|
critical: 'destructive',
|
||||||
warning: 'secondary',
|
warning: 'secondary',
|
||||||
info: 'default',
|
info: 'default'
|
||||||
},
|
},
|
||||||
execution: {
|
execution: {
|
||||||
failed: 'destructive',
|
failed: 'destructive',
|
||||||
@@ -30,13 +33,13 @@
|
|||||||
cancelled: 'destructive',
|
cancelled: 'destructive',
|
||||||
completed: 'default',
|
completed: 'default',
|
||||||
running: 'secondary',
|
running: 'secondary',
|
||||||
approved: 'secondary',
|
approved: 'secondary'
|
||||||
},
|
},
|
||||||
type: {
|
type: {
|
||||||
runbook: 'secondary',
|
runbook: 'secondary',
|
||||||
investigation: 'default',
|
investigation: 'default'
|
||||||
},
|
},
|
||||||
default: {},
|
default: {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function variant(): 'default' | 'secondary' | 'destructive' | 'outline' {
|
function variant(): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
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 { streaming, messages, chatFor } from '$lib/stores/chat'
|
||||||
import { activityLog, activityLogFor } from '$lib/stores/activity'
|
import { activityLog, activityLogFor } from '$lib/stores/activity'
|
||||||
import SessionGraph from './SessionGraph.svelte'
|
import SessionGraph from './SessionGraph.svelte'
|
||||||
@@ -43,12 +51,12 @@
|
|||||||
// last size so reopening restores it.
|
// last size so reopening restores it.
|
||||||
const COLLAPSED_SIZE = 6
|
const COLLAPSED_SIZE = 6
|
||||||
const OPEN_MIN_SIZE = 12
|
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
|
// Reopening must restore a concrete number, never `undefined` — the pane
|
||||||
// only re-triggers the library's resize/equalize pass when `size` changes
|
// only re-triggers the library's resize/equalize pass when `size` changes
|
||||||
// to a different *number*, so setting it back to `undefined` silently
|
// to a different *number*, so setting it back to `undefined` silently
|
||||||
// no-ops and leaves the section stuck at its collapsed height.
|
// 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) {
|
function toggleSection(i: number, isOpen: boolean) {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -70,7 +78,12 @@
|
|||||||
<div class="flex h-full min-h-0 flex-col">
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
|
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
|
||||||
<!-- Scope -->
|
<!-- 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
|
<button
|
||||||
type="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"
|
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
|
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>
|
<span>Scope</span>
|
||||||
{#if !scopeOpen}
|
{#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}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if scopeOpen}
|
{#if scopeOpen}
|
||||||
<div class="min-h-0 flex-1">
|
<div class="min-h-0 flex-1">
|
||||||
<SessionGraph messages={$messagesStore} touched={$touchedStore} healthDiffs={$healthDiffsStore} />
|
<SessionGraph
|
||||||
|
messages={$messagesStore}
|
||||||
|
touched={$touchedStore}
|
||||||
|
healthDiffs={$healthDiffsStore}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Pane>
|
</Pane>
|
||||||
|
|
||||||
<!-- Activity (merged plan + event log) -->
|
<!-- 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
|
<button
|
||||||
type="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"
|
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
|
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>
|
<span>Activity</span>
|
||||||
{#if $streamingStore && activityRunning > 0}
|
{#if $streamingStore && activityRunning > 0}
|
||||||
<Spinner class="size-3 text-primary" />
|
<Spinner class="size-3 text-primary" />
|
||||||
{/if}
|
{/if}
|
||||||
{#if planTotal > 0}
|
{#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}
|
||||||
{#if !activityOpen && planTotal === 0}
|
{#if !activityOpen && planTotal === 0}
|
||||||
{#if $taskStore?.goal}
|
{#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}
|
{: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}
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if activityOpen}
|
{#if activityOpen}
|
||||||
<div class="min-h-0 flex-1 overflow-hidden">
|
<div class="min-h-0 flex-1 overflow-hidden">
|
||||||
<UnifiedTimeline entries={$activityLogStore} planSteps={$planStepsStore} streaming={$streamingStore} />
|
<UnifiedTimeline
|
||||||
|
entries={$activityLogStore}
|
||||||
|
planSteps={$planStepsStore}
|
||||||
|
streaming={$streamingStore}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Pane>
|
</Pane>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<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 type { ToolCallResult } from '$lib/types'
|
||||||
|
import { toolActivityLabel } from '$lib/stores/activity'
|
||||||
|
|
||||||
let { tool }: { tool: ToolCallResult } = $props()
|
let { tool }: { tool: ToolCallResult } = $props()
|
||||||
let expanded = $state(false)
|
let expanded = $state(false)
|
||||||
@@ -11,11 +16,7 @@
|
|||||||
return 'done'
|
return 'done'
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusColor = $derived.by(() => {
|
const label = $derived(toolActivityLabel(tool))
|
||||||
if (status === 'running') return 'text-primary'
|
|
||||||
if (status === 'error') return 'text-destructive'
|
|
||||||
return 'text-primary'
|
|
||||||
})
|
|
||||||
|
|
||||||
const argsSummary = $derived.by(() => {
|
const argsSummary = $derived.by(() => {
|
||||||
if (!tool.args) return ''
|
if (!tool.args) return ''
|
||||||
@@ -25,61 +26,87 @@
|
|||||||
const val = typeof first[1] === 'string' ? first[1] : JSON.stringify(first[1])
|
const val = typeof first[1] === 'string' ? first[1] : JSON.stringify(first[1])
|
||||||
return `${first[0]}: ${val.length > 60 ? val.slice(0, 60) + '…' : val}`
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="tool-card rounded-lg border border-border/60 bg-card/40 overflow-hidden transition-all">
|
<div class="tool-row">
|
||||||
<button
|
<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)}
|
onclick={() => (expanded = !expanded)}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
|
disabled={!hasDetail}
|
||||||
>
|
>
|
||||||
<ChevronRight class="size-3 shrink-0 text-muted-foreground transition-transform {expanded ? 'rotate-90' : ''}" />
|
<span class="mt-px shrink-0 {status === 'error' ? 'text-destructive' : 'text-primary'}">
|
||||||
<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}">
|
|
||||||
{#if status === 'running'}
|
{#if status === 'running'}
|
||||||
<Loader2 class="size-3.5 animate-spin" />
|
<Loader2 class="size-3 animate-spin" />
|
||||||
{:else if status === 'error'}
|
{:else if status === 'error'}
|
||||||
<X class="size-3.5" />
|
<X class="size-3" />
|
||||||
{:else}
|
{:else}
|
||||||
<Check class="size-3.5" />
|
<Check class="size-3" />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
|
|
||||||
{#if expanded}
|
{#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}
|
{#if tool.args}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">Args</div>
|
<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>
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if tool.result !== undefined && tool.result !== null}
|
{#if tool.result !== undefined && tool.result !== null}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">Result</div>
|
<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>
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if tool.error}
|
{#if tool.error}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-[10px] font-semibold uppercase tracking-wider text-destructive mb-1">Error</div>
|
<div class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-destructive">
|
||||||
<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>
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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'
|
import FlagIcon from '@lucide/svelte/icons/flag'
|
||||||
|
|
||||||
// Merged plan + activity timeline, designed for the narrow rail:
|
// 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,
|
// - one continuous vertical "backbone"; every item owns a segment of it,
|
||||||
// colored by state (done = filled primary, running = faint primary,
|
// colored by state (done = filled primary, running = faint primary,
|
||||||
// pending/future = muted) so the line visibly fills in as work completes
|
// pending/future = muted) so the line visibly fills in as work completes
|
||||||
@@ -25,8 +28,13 @@
|
|||||||
// markers on the same backbone
|
// markers on the same backbone
|
||||||
// - the running step auto-expands and the view auto-scrolls to keep the
|
// - the running step auto-expands and the view auto-scrolls to keep the
|
||||||
// current step visible while the agent works (follow mode disengages if
|
// current step visible while the agent works (follow mode disengages if
|
||||||
// the operator scrolls up, re-engages when streaming starts again)
|
// the operator scrolls down into history, re-engages when streaming
|
||||||
let { entries, planSteps: steps, streaming = false }: {
|
// starts again)
|
||||||
|
let {
|
||||||
|
entries,
|
||||||
|
planSteps: steps,
|
||||||
|
streaming = false
|
||||||
|
}: {
|
||||||
entries: ActivityEntry[]
|
entries: ActivityEntry[]
|
||||||
planSteps: PlanStep[]
|
planSteps: PlanStep[]
|
||||||
streaming?: boolean
|
streaming?: boolean
|
||||||
@@ -63,18 +71,24 @@
|
|||||||
for (const s of steps) {
|
for (const s of steps) {
|
||||||
if (s.status === 'pending' && !entries.some((e) => e.stepSeq === s.seq)) {
|
if (s.status === 'pending' && !entries.some((e) => e.stepSeq === s.seq)) {
|
||||||
// Pending steps with no activity yet still show on the timeline so
|
// 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) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
const tools = entries.filter(
|
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)
|
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()
|
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) {
|
for (const e of entries) {
|
||||||
@@ -84,7 +98,15 @@
|
|||||||
out.push({ kind: 'entry', entry: e, ts: e.timestamp })
|
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
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -100,9 +122,11 @@
|
|||||||
let container = $state<HTMLDivElement | null>(null)
|
let container = $state<HTMLDivElement | null>(null)
|
||||||
let follow = $state(true)
|
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() {
|
function onScroll() {
|
||||||
if (!container) return
|
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.
|
// 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).
|
// entries land while following (instant, to avoid scroll-queue jank).
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!currentId || !follow || !container) return
|
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' })
|
?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
})
|
})
|
||||||
let lastEntryCount = 0
|
let lastEntryCount = 0
|
||||||
@@ -129,7 +154,7 @@
|
|||||||
? container.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
|
? container.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
|
||||||
: null
|
: null
|
||||||
if (target) target.scrollIntoView({ behavior: 'auto', block: 'nearest' })
|
if (target) target.scrollIntoView({ behavior: 'auto', block: 'nearest' })
|
||||||
else container.scrollTop = container.scrollHeight
|
else container.scrollTop = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Presentation helpers ──────────────────────────────────────────────────
|
// ── Presentation helpers ──────────────────────────────────────────────────
|
||||||
@@ -139,7 +164,12 @@
|
|||||||
// height so tools inside an expanded step stay on the line; first/last
|
// 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
|
// items clip theirs to their node/tool centers so the line never dangles
|
||||||
// past the timeline's ends.
|
// 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'
|
let color = 'bg-border'
|
||||||
if (status === 'done') color = 'bg-primary/60'
|
if (status === 'done') color = 'bg-primary/60'
|
||||||
else if (status === 'running') color = 'bg-primary/40'
|
else if (status === 'running') color = 'bg-primary/40'
|
||||||
@@ -154,11 +184,16 @@
|
|||||||
|
|
||||||
function entryIcon(entry: ActivityEntry) {
|
function entryIcon(entry: ActivityEntry) {
|
||||||
switch (entry.type) {
|
switch (entry.type) {
|
||||||
case 'goal': return MilestoneIcon
|
case 'goal':
|
||||||
case 'knowledge': return SparklesIcon
|
return MilestoneIcon
|
||||||
case 'complete': return FlagIcon
|
case 'knowledge':
|
||||||
case 'question': return HelpCircleIcon
|
return SparklesIcon
|
||||||
default: return WrenchIcon
|
case 'complete':
|
||||||
|
return FlagIcon
|
||||||
|
case 'question':
|
||||||
|
return HelpCircleIcon
|
||||||
|
default:
|
||||||
|
return WrenchIcon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function hhmm(ts: number): string {
|
function hhmm(ts: number): string {
|
||||||
@@ -167,10 +202,18 @@
|
|||||||
}
|
}
|
||||||
function hhmmss(ts: number): string {
|
function hhmmss(ts: number): string {
|
||||||
if (!ts) return ''
|
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 {
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -179,19 +222,57 @@
|
|||||||
{#if items.length === 0}
|
{#if items.length === 0}
|
||||||
<div class="flex flex-col items-center gap-2 px-3 py-6 text-center">
|
<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">
|
<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">
|
<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>
|
||||||
<circle cx="32" cy="55" r="4" fill="currentColor">
|
<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>
|
||||||
<circle cx="32" cy="55" r="4" fill="none" stroke="currentColor" stroke-width="1.5">
|
<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
|
||||||
<animate attributeName="opacity" values="0.6;0;0.6" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
|
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>
|
||||||
<circle cx="32" cy="88" r="4" fill="currentColor">
|
<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>
|
</circle>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-[11px] leading-relaxed text-muted-foreground">Waiting for activity…</p>
|
<p class="text-[11px] leading-relaxed text-muted-foreground">Waiting for activity…</p>
|
||||||
@@ -209,24 +290,42 @@
|
|||||||
{@const expandedWithTools = open && item.tools.length > 0}
|
{@const expandedWithTools = open && item.tools.length > 0}
|
||||||
<!-- Step node on the backbone -->
|
<!-- Step node on the backbone -->
|
||||||
<div class="relative" data-tl-id={item.step.id}>
|
<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
|
<button
|
||||||
type="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)}
|
onclick={() => expandable && toggleStep(item.step)}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
disabled={!expandable}
|
disabled={!expandable}
|
||||||
>
|
>
|
||||||
<!-- Filled status node -->
|
<!-- Filled status node -->
|
||||||
<span class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full
|
<span
|
||||||
{st === 'done' ? 'bg-primary'
|
class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full
|
||||||
: st === 'running' ? 'bg-background'
|
{st === 'done'
|
||||||
: st === 'failed' ? 'bg-destructive'
|
? 'bg-primary'
|
||||||
: st === 'blocked' ? 'bg-warning/25 border border-warning'
|
: st === 'running'
|
||||||
: st === 'skipped' || st === 'replaced' ? 'bg-muted'
|
? 'bg-background'
|
||||||
: 'bg-background border border-muted-foreground/40'}">
|
: 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'}
|
{#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" />
|
<Spinner class="relative size-3.5 text-primary" />
|
||||||
{:else if st === 'done'}
|
{:else if st === 'done'}
|
||||||
<CheckIcon class="size-2.5 text-primary-foreground" strokeWidth={3.5} />
|
<CheckIcon class="size-2.5 text-primary-foreground" strokeWidth={3.5} />
|
||||||
@@ -238,15 +337,28 @@
|
|||||||
<SlashIcon class="size-2 text-muted-foreground" strokeWidth={3} />
|
<SlashIcon class="size-2 text-muted-foreground" strokeWidth={3} />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</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}
|
{item.step.title}
|
||||||
</span>
|
</span>
|
||||||
{#if hhmm(item.ts)}
|
{#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}
|
||||||
{#if item.tools.length > 0}
|
{#if item.tools.length > 0}
|
||||||
<span class="shrink-0 text-muted-foreground/60">
|
<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>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
@@ -257,10 +369,19 @@
|
|||||||
{@const tOpen = expandedTools.has(tool.id)}
|
{@const tOpen = expandedTools.has(tool.id)}
|
||||||
<div class="relative" data-tl-id={tool.id}>
|
<div class="relative" data-tl-id={tool.id}>
|
||||||
<!-- Branch stub: backbone → tool -->
|
<!-- 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
|
<button
|
||||||
type="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)}
|
onclick={() => (tool.args || tool.detail) && toggleTool(tool.id)}
|
||||||
>
|
>
|
||||||
<span class="flex size-3 shrink-0 items-center justify-center">
|
<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} />
|
<CheckIcon class="size-2.5 text-primary/70" strokeWidth={3.5} />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</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}
|
{tool.description}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
{#if tOpen}
|
{#if tOpen}
|
||||||
<div transition:slide={{ duration: 120 }} class="flex flex-col gap-1 pb-1.5 pl-[52px] pr-3">
|
<div
|
||||||
<div class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70">
|
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 class="capitalize">{tool.status}</span>
|
||||||
<span aria-hidden="true">·</span>
|
<span aria-hidden="true">·</span>
|
||||||
<span>{hhmmss(tool.timestamp)}</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>
|
</div>
|
||||||
{#if tool.args}
|
{#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}
|
||||||
{#if tool.detail}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -304,14 +448,31 @@
|
|||||||
{@const Icon = entryIcon(e)}
|
{@const Icon = entryIcon(e)}
|
||||||
{@const eOpen = expandedTools.has(e.id)}
|
{@const eOpen = expandedTools.has(e.id)}
|
||||||
<div class="relative" data-tl-id={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
|
<button
|
||||||
type="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)}
|
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
|
<span
|
||||||
{e.status === 'failed' ? 'border-destructive text-destructive' : e.status === 'running' ? 'border-primary text-primary' : 'border-border text-primary'}">
|
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'}
|
{#if e.status === 'running'}
|
||||||
<Spinner class="size-2.5" />
|
<Spinner class="size-2.5" />
|
||||||
{:else if e.status === 'failed'}
|
{:else if e.status === 'failed'}
|
||||||
@@ -320,24 +481,43 @@
|
|||||||
<Icon class="size-2" strokeWidth={2.5} />
|
<Icon class="size-2" strokeWidth={2.5} />
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</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}
|
{e.description}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
{#if eOpen}
|
{#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">
|
<div class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70">
|
||||||
<span class="capitalize">{e.status}</span>
|
<span class="capitalize">{e.status}</span>
|
||||||
<span aria-hidden="true">·</span>
|
<span aria-hidden="true">·</span>
|
||||||
<span>{hhmmss(e.timestamp)}</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>
|
</div>
|
||||||
{#if e.args}
|
{#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}
|
||||||
{#if e.detail}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
type Row = Record<string, any>
|
type Row = Record<string, any>
|
||||||
|
|
||||||
const renderers: Record<string, unknown> = {
|
const renderers: Record<string, unknown> = {
|
||||||
'badge': BadgeRenderer,
|
badge: BadgeRenderer,
|
||||||
'health-dot': HealthDotRenderer,
|
'health-dot': HealthDotRenderer,
|
||||||
'relative-time': RelativeTimeRenderer,
|
'relative-time': RelativeTimeRenderer,
|
||||||
'date': DateRenderer,
|
date: DateRenderer,
|
||||||
'status-badge': StatusBadgeRenderer,
|
'status-badge': StatusBadgeRenderer
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -59,11 +59,11 @@
|
|||||||
// via $derived runes internally.
|
// via $derived runes internally.
|
||||||
const sortBuilders = new Map<string, ReturnType<typeof table.createSort>>()
|
const sortBuilders = new Map<string, ReturnType<typeof table.createSort>>()
|
||||||
|
|
||||||
function getSortBuilder(key: string) {
|
function getSortBuilder(col: DataTableColumn<Row>) {
|
||||||
if (!sortBuilders.has(key)) {
|
if (!sortBuilders.has(col.key)) {
|
||||||
sortBuilders.set(key, table.createSort(key))
|
sortBuilders.set(col.key, table.createSort(col.accessor ?? col.key))
|
||||||
}
|
}
|
||||||
return sortBuilders.get(key)!
|
return sortBuilders.get(col.key)!
|
||||||
}
|
}
|
||||||
|
|
||||||
let search = $state.raw(
|
let search = $state.raw(
|
||||||
@@ -117,13 +117,13 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={['flex flex-col h-full min-h-0', className].filter(Boolean).join(' ')}>
|
<div class={['flex flex-col h-full min-h-0', className].filter(Boolean).join(' ')}>
|
||||||
<Toolbar {table} {searchable} {paginated} onSearchChange={handleSearch}>
|
<Toolbar {table} {searchable} {paginated} onSearchChange={handleSearch} {children} />
|
||||||
{#if children}
|
|
||||||
{@render children()}
|
|
||||||
{/if}
|
|
||||||
</Toolbar>
|
|
||||||
|
|
||||||
<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">
|
<table class="w-full caption-bottom text-sm table-fixed">
|
||||||
<thead class="[&_tr]:border-b">
|
<thead class="[&_tr]:border-b">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -131,14 +131,16 @@
|
|||||||
<th
|
<th
|
||||||
class={[
|
class={[
|
||||||
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap',
|
'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,
|
col.headerClass,
|
||||||
colAlignClass(col)
|
colAlignClass(col)
|
||||||
].filter(Boolean).join(' ')}
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
style={colStyle(col)}
|
style={colStyle(col)}
|
||||||
>
|
>
|
||||||
{#if col.sortable !== false}
|
{#if col.sortable !== false}
|
||||||
{@const sb = getSortBuilder(col.key)}
|
{@const sb = getSortBuilder(col)}
|
||||||
<SortHeader
|
<SortHeader
|
||||||
label={col.header}
|
label={col.header}
|
||||||
sorted={sb.isActive}
|
sorted={sb.isActive}
|
||||||
@@ -152,64 +154,86 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="[&_tr:last-child]:border-0">
|
</table>
|
||||||
{#if loading}
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||||
{#each skeletonWidths as w, i}
|
<table class="w-full caption-bottom text-sm table-fixed">
|
||||||
<tr class="border-b transition-colors hover:bg-transparent">
|
<tbody class="[&_tr:last-child]:border-0">
|
||||||
{#each visibleCols as col (col.key)}
|
{#if loading}
|
||||||
<td class={[col.class, colAlignClass(col), colTruncateClass(col)].filter(Boolean).join(' ')} style={colStyle(col)}>
|
{#each skeletonWidths as w, i}
|
||||||
<Skeleton class="h-4 {skeletonWidths[(i + visibleCols.indexOf(col)) % skeletonWidths.length]}" />
|
<tr class="border-b transition-colors hover:bg-transparent">
|
||||||
</td>
|
{#each visibleCols as col (col.key)}
|
||||||
{/each}
|
<td
|
||||||
</tr>
|
class={[col.class, colAlignClass(col), colTruncateClass(col)]
|
||||||
{/each}
|
.filter(Boolean)
|
||||||
{:else if rows.length === 0}
|
.join(' ')}
|
||||||
<EmptyState message={emptyMessage} colspan={visibleCols.length} />
|
style={colStyle(col)}
|
||||||
{:else}
|
>
|
||||||
{#each rows as row, idx (row.id ?? row.slug ?? `row-${idx}`)}
|
<Skeleton
|
||||||
<tr
|
class="h-4 {skeletonWidths[
|
||||||
class={[
|
(i + visibleCols.indexOf(col)) % skeletonWidths.length
|
||||||
'border-b transition-colors hover:bg-muted/50',
|
]}"
|
||||||
onRowClick ? 'cursor-pointer' : '',
|
/>
|
||||||
selected === (row.id ?? row.slug) ? 'bg-muted' : ''
|
</td>
|
||||||
].filter(Boolean).join(' ')}
|
{/each}
|
||||||
tabindex={onRowClick ? 0 : undefined}
|
</tr>
|
||||||
onclick={onRowClick ? () => onRowClick(row) : undefined}
|
{/each}
|
||||||
onkeydown={onRowClick
|
{:else if rows.length === 0}
|
||||||
? (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRowClick(row) } }
|
<EmptyState message={emptyMessage} colspan={visibleCols.length} />
|
||||||
: undefined}
|
{:else}
|
||||||
>
|
{#each rows as row, idx (row.id ?? row.slug ?? `row-${idx}`)}
|
||||||
{#each visibleCols as col (col.key)}
|
<tr
|
||||||
{@const val = resolveCellValue(row, col)}
|
class={[
|
||||||
<td
|
'border-b transition-colors hover:bg-muted/50',
|
||||||
class={[
|
onRowClick ? 'cursor-pointer' : '',
|
||||||
'p-2 align-middle whitespace-nowrap',
|
selected === (row.id ?? row.slug) ? 'bg-muted' : ''
|
||||||
col.class,
|
]
|
||||||
colAlignClass(col),
|
.filter(Boolean)
|
||||||
colTruncateClass(col)
|
.join(' ')}
|
||||||
].filter(Boolean).join(' ')}
|
tabindex={onRowClick ? 0 : undefined}
|
||||||
style={colStyle(col)}
|
onclick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
>
|
onkeydown={onRowClick
|
||||||
{#if typeof col.render === 'string'}
|
? (e: KeyboardEvent) => {
|
||||||
{@const R = renderers[col.render]}
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
{#if R}
|
e.preventDefault()
|
||||||
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
|
onRowClick(row)
|
||||||
<R value={val} {row} {...(col.renderProps ?? {})} />
|
}
|
||||||
|
}
|
||||||
|
: 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}
|
{:else}
|
||||||
{String(val ?? '—')}
|
{String(val ?? '—')}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if typeof col.render === 'function'}
|
</td>
|
||||||
<col.render {row} value={val} {...(col.renderProps ?? {})} />
|
{/each}
|
||||||
{:else}
|
</tr>
|
||||||
{String(val ?? '—')}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</tbody>
|
||||||
{/each}
|
</table>
|
||||||
</tr>
|
</div>
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if paginated}
|
{#if paginated}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
import * as Select from '$lib/components/ui/select'
|
import * as Select from '$lib/components/ui/select'
|
||||||
import type { TableHandler } from '@vincjo/datatables'
|
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]
|
const options = [10, 20, 50, 100]
|
||||||
let value = $state('20')
|
let value = $state('20')
|
||||||
|
|||||||
@@ -16,6 +16,13 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button size="sm" disabled={deciding === row.id} onclick={() => onApprove?.(row.id)}>Approve</Button>
|
<Button size="sm" disabled={deciding === row.id} onclick={() => onApprove?.(row.id)}
|
||||||
<Button size="sm" variant="destructive" disabled={deciding === row.id} onclick={() => onDeny?.(row.id)}>Deny</Button>
|
>Approve</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deciding === row.id}
|
||||||
|
onclick={() => onDeny?.(row.id)}>Deny</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Badge, type BadgeVariant } from '$lib/components/ui/badge'
|
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>
|
</script>
|
||||||
|
|
||||||
<Badge {variant}>{String(value ?? '—')}</Badge>
|
<Badge {variant}>{String(value ?? '—')}</Badge>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if health}
|
{#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="size-2 shrink-0 rounded-full {dot[row.health ?? ''] ?? ''}"></span>
|
||||||
<span class="text-muted-foreground">{relativeTime(lastCheck)}</span>
|
<span class="text-muted-foreground">{relativeTime(lastCheck)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -19,8 +19,13 @@
|
|||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
{#if row.state === 'raised'}
|
{#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}
|
{/if}
|
||||||
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onMute?.(row.id)}>Mute 1h</Button>
|
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onMute?.(row.id)}
|
||||||
<Button size="sm" disabled={acting === row.id} onclick={() => onResolve?.(row.id)}>Resolve</Button>
|
>Mute 1h</Button
|
||||||
|
>
|
||||||
|
<Button size="sm" disabled={acting === row.id} onclick={() => onResolve?.(row.id)}>Resolve</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
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 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: {
|
risk: {
|
||||||
destructive: 'destructive',
|
destructive: 'destructive',
|
||||||
config_mutation: 'secondary',
|
config_mutation: 'secondary',
|
||||||
default: 'default',
|
default: 'default'
|
||||||
},
|
},
|
||||||
severity: {
|
severity: {
|
||||||
critical: 'destructive',
|
critical: 'destructive',
|
||||||
warning: 'secondary',
|
warning: 'secondary',
|
||||||
info: 'default',
|
info: 'default',
|
||||||
default: 'default',
|
default: 'default'
|
||||||
},
|
},
|
||||||
execution: {
|
execution: {
|
||||||
failed: 'destructive',
|
failed: 'destructive',
|
||||||
@@ -25,19 +32,19 @@
|
|||||||
completed: 'default',
|
completed: 'default',
|
||||||
running: 'secondary',
|
running: 'secondary',
|
||||||
approved: 'secondary',
|
approved: 'secondary',
|
||||||
default: 'outline',
|
default: 'outline'
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
active: 'default',
|
active: 'default',
|
||||||
healthy: 'default',
|
healthy: 'default',
|
||||||
default: 'outline',
|
default: 'outline'
|
||||||
},
|
},
|
||||||
type: {
|
type: {
|
||||||
runbook: 'secondary',
|
runbook: 'secondary',
|
||||||
investigation: 'default',
|
investigation: 'default',
|
||||||
default: 'outline',
|
default: 'outline'
|
||||||
},
|
},
|
||||||
default: { default: 'default' },
|
default: { default: 'default' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const variant = $derived.by(() => {
|
const variant = $derived.by(() => {
|
||||||
|
|||||||
@@ -9,12 +9,14 @@
|
|||||||
import { iconPositions, resetIconLayout } from '$lib/stores/icons'
|
import { iconPositions, resetIconLayout } from '$lib/stores/icons'
|
||||||
import { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows'
|
import { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows'
|
||||||
import { summary } from '$lib/stores/context'
|
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 DesktopIcon from './DesktopIcon.svelte'
|
||||||
import TaskLauncher from './TaskLauncher.svelte'
|
import TaskLauncher from './TaskLauncher.svelte'
|
||||||
import WindowLayer from './WindowLayer.svelte'
|
import WindowLayer from './WindowLayer.svelte'
|
||||||
import DockedLayer from './DockedLayer.svelte'
|
import DockedLayer from './DockedLayer.svelte'
|
||||||
import Taskbar from './Taskbar.svelte'
|
import Taskbar from './Taskbar.svelte'
|
||||||
|
import * as ContextMenu from '$lib/components/ui/context-menu'
|
||||||
import LayersIcon from '@lucide/svelte/icons/layers'
|
import LayersIcon from '@lucide/svelte/icons/layers'
|
||||||
import Rows3Icon from '@lucide/svelte/icons/rows-3'
|
import Rows3Icon from '@lucide/svelte/icons/rows-3'
|
||||||
import MonitorIcon from '@lucide/svelte/icons/monitor'
|
import MonitorIcon from '@lucide/svelte/icons/monitor'
|
||||||
@@ -22,37 +24,18 @@
|
|||||||
import Undo2Icon from '@lucide/svelte/icons/undo-2'
|
import Undo2Icon from '@lucide/svelte/icons/undo-2'
|
||||||
import Redo2Icon from '@lucide/svelte/icons/redo-2'
|
import Redo2Icon from '@lucide/svelte/icons/redo-2'
|
||||||
|
|
||||||
// Clicking the bare desktop (not an icon, not a window) blurs the focused
|
// canUndo/canRedo are plain wmkit method calls (not stores), so they're
|
||||||
// window — the familiar "click empty desktop to deselect" affordance.
|
// snapshotted once when the menu opens (onOpenChange) rather than read
|
||||||
function onSurfaceClick(e: MouseEvent) {
|
// reactively in the template. bits-ui auto-dismisses on item select and
|
||||||
if (e.currentTarget === e.target) wm.blur()
|
// on Escape / click-away, so the old manual menuPos/closeMenu/runMenuAction
|
||||||
}
|
// machinery is gone.
|
||||||
|
|
||||||
// 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)
|
|
||||||
let menuCanUndo = $state(false)
|
let menuCanUndo = $state(false)
|
||||||
let menuCanRedo = $state(false)
|
let menuCanRedo = $state(false)
|
||||||
|
|
||||||
function onSurfaceContextMenu(e: MouseEvent) {
|
function onOpenChange(open: boolean) {
|
||||||
if (e.currentTarget !== e.target) return
|
if (!open) return
|
||||||
e.preventDefault()
|
|
||||||
menuCanUndo = wm.canUndo()
|
menuCanUndo = wm.canUndo()
|
||||||
menuCanRedo = wm.canRedo()
|
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,
|
// 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
|
// fights the browser's own text-undo inside the task input or a form
|
||||||
// field.
|
// field.
|
||||||
function onWindowKeydown(e: KeyboardEvent) {
|
function onWindowKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape' && menuPos) {
|
|
||||||
closeMenu()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const target = e.target as HTMLElement | null
|
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 (editable) return
|
||||||
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'z') return
|
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'z') return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (e.shiftKey) wm.redo()
|
if (e.shiftKey) wm.redo()
|
||||||
else wm.undo()
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onWindowKeydown} onclick={closeMenu} />
|
<svelte:window onkeydown={onWindowKeydown} />
|
||||||
|
|
||||||
<div class="fixed inset-0 flex flex-col">
|
<div class="fixed inset-0 flex flex-col">
|
||||||
<div
|
<div class="relative min-h-0 flex-1 overflow-hidden" role="presentation">
|
||||||
class="relative min-h-0 flex-1 overflow-hidden"
|
{#if bgActive}
|
||||||
role="presentation"
|
<div
|
||||||
onclick={onSurfaceClick}
|
class="pointer-events-none absolute inset-0 z-0 overflow-hidden"
|
||||||
oncontextmenu={onSurfaceContextMenu}
|
aria-hidden="true"
|
||||||
>
|
style={bgOuterStyle}
|
||||||
<GraphBackground />
|
>
|
||||||
|
<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">
|
<div class="pointer-events-none absolute inset-0 z-0">
|
||||||
{#each $apps as app (app.id)}
|
{#each $apps as app (app.id)}
|
||||||
@@ -107,58 +150,3 @@
|
|||||||
|
|
||||||
<Taskbar />
|
<Taskbar />
|
||||||
</div>
|
</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}
|
onkeydown={onKeydown}
|
||||||
title={app.title}
|
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" />
|
<app.icon class="size-5" />
|
||||||
{#if badge > 0}
|
{#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}
|
{badge > 99 ? '99+' : badge}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@
|
|||||||
{@const C = mod.default}
|
{@const C = mod.default}
|
||||||
<C />
|
<C />
|
||||||
{:catch error}
|
{: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}
|
Failed to load app: {(error as Error).message}
|
||||||
</div>
|
</div>
|
||||||
{/await}
|
{/await}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
messages={[]}
|
messages={[]}
|
||||||
streaming={false}
|
streaming={false}
|
||||||
connectionState="connected"
|
connectionState="connected"
|
||||||
onSend={onSend}
|
{onSend}
|
||||||
onCancel={() => {}}
|
onCancel={() => {}}
|
||||||
onReconnect={() => {}}
|
onReconnect={() => {}}
|
||||||
onDismissError={() => {}}
|
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"
|
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">
|
<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">
|
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Start task">
|
||||||
<ArrowUpIcon />
|
<ArrowUpIcon />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -51,7 +51,6 @@
|
|||||||
wm.focus(id)
|
wm.focus(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-11 shrink-0 items-center gap-1.5 border-t bg-muted/30 px-2">
|
<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 ===
|
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'
|
win.id && win.stage !== 'minimized'
|
||||||
? 'border-primary/50 bg-primary/10 text-foreground'
|
? '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)}
|
onclick={() => toggle(win.id, win)}
|
||||||
title={win.title}
|
title={win.title}
|
||||||
>
|
>
|
||||||
{#if Icon}<Icon class="size-3.5 shrink-0" />{/if}
|
{#if Icon}<Icon class="size-3.5 shrink-0" />{/if}
|
||||||
<span class="min-w-0 truncate">{truncateMiddle(win.title, 26)}</span>
|
<span class="min-w-0 truncate">{truncateMiddle(win.title, 26)}</span>
|
||||||
{#if badge > 0}
|
{#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}
|
{badge > 99 ? '99+' : badge}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -109,12 +113,12 @@
|
|||||||
<div class="flex shrink-0 items-center gap-0.5">
|
<div class="flex shrink-0 items-center gap-0.5">
|
||||||
<button
|
<button
|
||||||
type="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()}
|
onclick={() => toggleTheme()}
|
||||||
title="Cycle theme"
|
title="Cycle theme ({THEME_LABELS[getTheme()]})"
|
||||||
|
aria-label="Cycle theme, currently {THEME_LABELS[getTheme()]}"
|
||||||
>
|
>
|
||||||
<PaletteIcon class="size-4" />
|
<PaletteIcon class="size-4" />
|
||||||
<span class="hidden text-xs sm:inline">{THEME_LABELS[getTheme()]}</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -9,7 +9,14 @@
|
|||||||
// session:<id> -> SessionChatWindow (windows.ts openTaskWindow)
|
// session:<id> -> SessionChatWindow (windows.ts openTaskWindow)
|
||||||
// new-task -> NewTaskChat (windows.ts openNewTaskWindow)
|
// new-task -> NewTaskChat (windows.ts openNewTaskWindow)
|
||||||
// anything else -> entity slug -> EntityDetailContent
|
// 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 { appById, appIdFromWindowId } from '$lib/apps'
|
||||||
import EntityDetailContent from '../EntityDetailContent.svelte'
|
import EntityDetailContent from '../EntityDetailContent.svelte'
|
||||||
import SessionChatWindow from '../SessionChatWindow.svelte'
|
import SessionChatWindow from '../SessionChatWindow.svelte'
|
||||||
@@ -32,6 +39,22 @@
|
|||||||
if (appId && !idx.has(appId)) wm.close(id)
|
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>
|
</script>
|
||||||
|
|
||||||
<div use:dk.desktop class="absolute inset-0 z-40 pointer-events-none">
|
<div use:dk.desktop class="absolute inset-0 z-40 pointer-events-none">
|
||||||
@@ -41,8 +64,15 @@
|
|||||||
{@const app = appId ? $appById.get(appId) : undefined}
|
{@const app = appId ? $appById.get(appId) : undefined}
|
||||||
{#if win && (!appId || app)}
|
{#if win && (!appId || app)}
|
||||||
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
|
<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">
|
<header
|
||||||
<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>
|
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">
|
<div class="flex shrink-0 items-center gap-0.5">
|
||||||
<button
|
<button
|
||||||
type="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>
|
<script lang="ts" module>
|
||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { type VariantProps, tv } from 'tailwind-variants'
|
||||||
|
|
||||||
export const badgeVariants = tv({
|
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",
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||||
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/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",
|
destructive:
|
||||||
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
'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',
|
||||||
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
|
||||||
},
|
link: 'text-primary underline-offset-4 hover:underline'
|
||||||
},
|
}
|
||||||
defaultVariants: {
|
},
|
||||||
variant: "default",
|
defaultVariants: {
|
||||||
},
|
variant: 'default'
|
||||||
});
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
export type BadgeVariant = VariantProps<typeof badgeVariants>['variant']
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
import type { HTMLAnchorAttributes } from 'svelte/elements'
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
href,
|
href,
|
||||||
class: className,
|
class: className,
|
||||||
variant = "default",
|
variant = 'default',
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||||
variant?: BadgeVariant;
|
variant?: BadgeVariant
|
||||||
} = $props();
|
} = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:element
|
<svelte:element
|
||||||
this={href ? "a" : "span"}
|
this={href ? 'a' : 'span'}
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="badge"
|
data-slot="badge"
|
||||||
{href}
|
{href}
|
||||||
class={cn(badgeVariants({ variant }), className)}
|
class={cn(badgeVariants({ variant }), className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</svelte:element>
|
</svelte:element>
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as Badge } from "./badge.svelte";
|
export { default as Badge } from './badge.svelte'
|
||||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
export { badgeVariants, type BadgeVariant } from './badge.svelte'
|
||||||
|
|||||||
@@ -1,82 +1,89 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'
|
||||||
import { type VariantProps, tv } from "tailwind-variants";
|
import { type VariantProps, tv } from 'tailwind-variants'
|
||||||
|
|
||||||
export const buttonVariants = tv({
|
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",
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
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",
|
outline:
|
||||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
'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',
|
||||||
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
secondary:
|
||||||
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",
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
ghost:
|
||||||
},
|
'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||||
size: {
|
destructive:
|
||||||
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",
|
'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',
|
||||||
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",
|
link: 'text-primary underline-offset-4 hover:underline'
|
||||||
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",
|
size: {
|
||||||
icon: "size-9",
|
default:
|
||||||
"icon-xs": "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
|
'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',
|
||||||
"icon-sm": "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
|
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",
|
||||||
"icon-lg": "size-10",
|
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',
|
||||||
defaultVariants: {
|
'icon-xs':
|
||||||
variant: "default",
|
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
size: "default",
|
'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 ButtonVariant = VariantProps<typeof buttonVariants>['variant']
|
||||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
export type ButtonSize = VariantProps<typeof buttonVariants>['size']
|
||||||
|
|
||||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||||
WithElementRef<HTMLAnchorAttributes> & {
|
WithElementRef<HTMLAnchorAttributes> & {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant
|
||||||
size?: ButtonSize;
|
size?: ButtonSize
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let {
|
let {
|
||||||
class: className,
|
class: className,
|
||||||
variant = "default",
|
variant = 'default',
|
||||||
size = "default",
|
size = 'default',
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
href = undefined,
|
href = undefined,
|
||||||
type = "button",
|
type = 'button',
|
||||||
disabled,
|
disabled,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: ButtonProps = $props();
|
}: ButtonProps = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if href}
|
{#if href}
|
||||||
<a
|
<a
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
class={cn(buttonVariants({ variant, size }), className)}
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
href={disabled ? undefined : href}
|
href={disabled ? undefined : href}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
role={disabled ? "link" : undefined}
|
role={disabled ? 'link' : undefined}
|
||||||
tabindex={disabled ? -1 : undefined}
|
tabindex={disabled ? -1 : undefined}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
class={cn(buttonVariants({ variant, size }), className)}
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
{type}
|
{type}
|
||||||
{disabled}
|
{disabled}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import Root, {
|
import Root, {
|
||||||
type ButtonProps,
|
type ButtonProps,
|
||||||
type ButtonSize,
|
type ButtonSize,
|
||||||
type ButtonVariant,
|
type ButtonVariant,
|
||||||
buttonVariants,
|
buttonVariants
|
||||||
} from "./button.svelte";
|
} from './button.svelte'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
type ButtonProps as Props,
|
type ButtonProps as Props,
|
||||||
//
|
//
|
||||||
Root as Button,
|
Root as Button,
|
||||||
buttonVariants,
|
buttonVariants,
|
||||||
type ButtonProps,
|
type ButtonProps,
|
||||||
type ButtonSize,
|
type ButtonSize,
|
||||||
type ButtonVariant,
|
type ButtonVariant
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
class={cn(
|
class={cn(
|
||||||
"cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
'cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements'
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-content"
|
data-slot="card-content"
|
||||||
class={cn("px-6 group-data-[size=sm]/card:px-4", className)}
|
class={cn('px-6 group-data-[size=sm]/card:px-4', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements'
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-description"
|
data-slot="card-description"
|
||||||
class={cn("text-muted-foreground text-sm", className)}
|
class={cn('text-muted-foreground text-sm', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-footer"
|
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)}
|
class={cn(
|
||||||
{...restProps}
|
'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>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
class={cn(
|
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]",
|
'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
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements'
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card-title"
|
data-slot="card-title"
|
||||||
class={cn("text-base leading-normal font-medium group-data-[size=sm]/card:text-sm", className)}
|
class={cn('text-base leading-normal font-medium group-data-[size=sm]/card:text-sm', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements'
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
size = "default",
|
size = 'default',
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: 'default' | 'sm' } = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
data-size={size}
|
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)}
|
class={cn(
|
||||||
{...restProps}
|
'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>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import Root from "./card.svelte";
|
import Root from './card.svelte'
|
||||||
import Content from "./card-content.svelte";
|
import Content from './card-content.svelte'
|
||||||
import Description from "./card-description.svelte";
|
import Description from './card-description.svelte'
|
||||||
import Footer from "./card-footer.svelte";
|
import Footer from './card-footer.svelte'
|
||||||
import Header from "./card-header.svelte";
|
import Header from './card-header.svelte'
|
||||||
import Title from "./card-title.svelte";
|
import Title from './card-title.svelte'
|
||||||
import Action from "./card-action.svelte";
|
import Action from './card-action.svelte'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
Content,
|
Content,
|
||||||
Description,
|
Description,
|
||||||
Footer,
|
Footer,
|
||||||
Header,
|
Header,
|
||||||
Title,
|
Title,
|
||||||
Action,
|
Action,
|
||||||
//
|
//
|
||||||
Root as Card,
|
Root as Card,
|
||||||
Content as CardContent,
|
Content as CardContent,
|
||||||
Description as CardDescription,
|
Description as CardDescription,
|
||||||
Footer as CardFooter,
|
Footer as CardFooter,
|
||||||
Header as CardHeader,
|
Header as CardHeader,
|
||||||
Title as CardTitle,
|
Title as CardTitle,
|
||||||
Action as CardAction,
|
Action as CardAction
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -25,7 +25,10 @@
|
|||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{#snippet children({ checked, indeterminate })}
|
{#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}
|
{#if indeterminate}
|
||||||
<MinusIcon class="size-3.5" />
|
<MinusIcon class="size-3.5" />
|
||||||
{:else if checked}
|
{:else if checked}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />
|
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />
|
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
import { Collapsible as CollapsiblePrimitive } from 'bits-ui'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
open = $bindable(false),
|
open = $bindable(false),
|
||||||
...restProps
|
...restProps
|
||||||
}: CollapsiblePrimitive.RootProps = $props();
|
}: CollapsiblePrimitive.RootProps = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />
|
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import Root from "./collapsible.svelte";
|
import Root from './collapsible.svelte'
|
||||||
import Trigger from "./collapsible-trigger.svelte";
|
import Trigger from './collapsible-trigger.svelte'
|
||||||
import Content from "./collapsible-content.svelte";
|
import Content from './collapsible-content.svelte'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
Content,
|
Content,
|
||||||
Trigger,
|
Trigger,
|
||||||
//
|
//
|
||||||
Root as Collapsible,
|
Root as Collapsible,
|
||||||
Content as CollapsibleContent,
|
Content as CollapsibleContent,
|
||||||
Trigger as CollapsibleTrigger,
|
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">
|
<script lang="ts">
|
||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
import { Dialog as DialogPrimitive } from 'bits-ui'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
type = "button",
|
type = 'button',
|
||||||
...restProps
|
...restProps
|
||||||
}: DialogPrimitive.CloseProps = $props();
|
}: DialogPrimitive.CloseProps = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {type} {...restProps} />
|
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {type} {...restProps} />
|
||||||
|
|||||||
@@ -1,48 +1,48 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
import { Dialog as DialogPrimitive } from 'bits-ui'
|
||||||
import DialogPortal from "./dialog-portal.svelte";
|
import DialogPortal from './dialog-portal.svelte'
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from 'svelte'
|
||||||
import * as Dialog from "./index.js";
|
import * as Dialog from './index.js'
|
||||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js'
|
||||||
import type { ComponentProps } from "svelte";
|
import type { ComponentProps } from 'svelte'
|
||||||
import { Button } from "$lib/components/ui/button/index.js";
|
import { Button } from '$lib/components/ui/button/index.js'
|
||||||
import XIcon from '@lucide/svelte/icons/x';
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
portalProps,
|
portalProps,
|
||||||
children,
|
children,
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>
|
||||||
children: Snippet;
|
children: Snippet
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean
|
||||||
} = $props();
|
} = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DialogPortal {...portalProps}>
|
<DialogPortal {...portalProps}>
|
||||||
<Dialog.Overlay />
|
<Dialog.Overlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
class={cn(
|
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",
|
'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
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{#if showCloseButton}
|
{#if showCloseButton}
|
||||||
<DialogPrimitive.Close data-slot="dialog-close">
|
<DialogPrimitive.Close data-slot="dialog-close">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<Button variant="ghost" class="absolute top-4 right-4" size="icon-sm" {...props}>
|
<Button variant="ghost" class="absolute top-4 right-4" size="icon-sm" {...props}>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
<span class="sr-only">Close</span>
|
<span class="sr-only">Close</span>
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
{/if}
|
{/if}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
import { Dialog as DialogPrimitive } from 'bits-ui'
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: DialogPrimitive.DescriptionProps = $props();
|
}: DialogPrimitive.DescriptionProps = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
|
class={cn(
|
||||||
{...restProps}
|
'text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements'
|
||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
import { Dialog as DialogPrimitive } from 'bits-ui'
|
||||||
import { Button } from "$lib/components/ui/button/index.js";
|
import { Button } from '$lib/components/ui/button/index.js'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
showCloseButton = false,
|
showCloseButton = false,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean
|
||||||
} = $props();
|
} = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
class={cn("gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
class={cn('gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{#if showCloseButton}
|
{#if showCloseButton}
|
||||||
<DialogPrimitive.Close>
|
<DialogPrimitive.Close>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<Button variant="outline" {...props}>Close</Button>
|
<Button variant="outline" {...props}>Close</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from "svelte/elements";
|
import type { HTMLAttributes } from 'svelte/elements'
|
||||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
import { cn, type WithElementRef } from '$lib/utils.js'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="dialog-header"
|
data-slot="dialog-header"
|
||||||
class={cn("gap-2 flex flex-col", className)}
|
class={cn('gap-2 flex flex-col', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
import { Dialog as DialogPrimitive } from 'bits-ui'
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: DialogPrimitive.OverlayProps = $props();
|
}: DialogPrimitive.OverlayProps = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="dialog-overlay"
|
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)}
|
class={cn(
|
||||||
{...restProps}
|
'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">
|
<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>
|
</script>
|
||||||
|
|
||||||
<DialogPrimitive.Portal {...restProps} />
|
<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