5 Commits

Author SHA1 Message Date
614c38ea7c docs: plan chat-sessions fixes from real production usage data
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Inspected the live agent_sessions/agent_messages tables on mac-mini and
found silent empty responses, a canned non-English refusal after 22 tool
calls, 70-call fan-out for simple fleet questions, 100KB+ persisted
messages, and no session delete/title hygiene.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 10:09:34 +02:00
22412d2fa3 feat: group tool calls per turn + session entity graph in chat rail
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Two chat UX changes (share Chat.svelte, committed together).

Tool-call grouping (ToolCallGroup.svelte):
- Replaced the one-<details>-per-tool-call list with a single
  collapsible group per assistant turn, headed by tool count + a
  name preview and a status icon (spinning wrench in progress, check
  done, X on error).
- The group auto-collapses the instant its turn finishes streaming, so
  a completed round shows as one compact pill; historical/loaded turns
  start collapsed. The auto-collapse fires once at the streaming→done
  transition, leaving manual toggles alone afterward.

Session graph rail (SessionGraph.svelte) — replaces the old
ContextRail (fleet health / pending approvals / live events), which is
deleted:
- A force-directed graph that starts empty (animated constellation
  empty state) and grows as the conversation references entities.
  Slugs are extracted from message text and tool *arguments* only —
  never bulk result rows, so a single get_health_summary doesn't dump
  all 168 entities — then validated against the backend via fetchGraph
  (cached) with check/execution probe entities excluded. Nodes are
  colored by health; edges appear once both endpoints are present.
- Clicking a node highlights it and its neighbors and opens an inline
  detail panel below: slug/type/state, health + freshness, top
  attributes, in-graph relations (clickable to hop), and a Full detail
  button opening the entity sheet.
- The rail is resizable via a drag handle (260–620px, persisted to
  localStorage). The header's global fleet-health dots are unchanged;
  only the right-rail content was replaced.

Risk: reversible_low (UI-only). The slug extractor is scoped to
focused mentions by design; edges may be slightly incomplete since
only root-fetched entities contribute edges, which is acceptable for a
session overview.

Verification: verified in the browser preview — loading a real session
built a 4-node graph (hubris/caddy/netbird-vps/strong) with the
hubris→caddy relationship edge; clicking hubris showed
"proxmox-host · active · healthy · checked 40s ago" with attributes and
relations; dragging the handle resized 320→440px and persisted; a
34-tool historical turn renders as one collapsed pill that expands on
click. tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 09:48:21 +02:00
5686b9de40 fix: white favicon, sidebar active-state bug, app-wide pointer cursor
Three UI issues reported after the neutral-gray redesign:

- favicon.svg was still filled #58a6ff (the pre-redesign accent blue);
  changed to white to match the sidebar logo mark.
- Sidebar nav items all showed a filled background even when inactive.
  Root cause: sidebar-menu-button.svelte (and -sub-button) rendered
  `data-active="false"` as a literal attribute, but Tailwind's bare
  `data-active:` variant matches attribute *presence*, not value — so
  data-active:bg-sidebar-accent applied to every item regardless of
  state. Fixed by emitting the attribute only when active
  (`isActive || undefined`), a latent bug in the vendored shadcn
  component that read as intentional until flagged.
- Tailwind's preflight resets <button> to cursor: default, so no button
  in the app showed a pointer. Added one base rule restoring
  cursor: pointer for buttons, [role=button], links, summary, and
  select (respecting :disabled / aria-disabled) rather than annotating
  each call site — covers new interactive elements automatically.

Risk: reversible_low (UI-only).

Verification: verified in the browser preview that inactive sidebar
items are transparent (only the current page shows a background),
nav buttons report cursor: pointer via computed styles, and the
favicon renders white in the tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 09:47:47 +02:00
aa6017e0ca fix: layout overflow regression + adopt true neutral gray theme
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Problem: two issues surfaced after the dashboard-01 shell change
(cbfd09c). (1) Sidebar.Inset previously had an explicit h-svh that
hard-capped the app's height at the viewport; adding variant="inset"
put a margin on that same fixed-height box, pushing it taller than the
viewport with nothing left in the chain to cap it (Sidebar.Provider's
own wrapper only sets min-h-svh — a floor, not a ceiling). Result: the
whole page scrolled as one long document instead of each page's own
content scrolling internally with the header pinned — confirmed via
computed styles, e.g. Entities.svelte's table wrapper measured
scrollHeight 6531px against a 900px viewport, all of it spilling past
body instead of scrolling in its own rounded-border container.
(2) The color palette was GitHub-dark-inspired (blue-tinted grays:
#0d1117 bg, #58a6ff primary/accent) rather than the neutral grays the
shadcn-svelte dashboard-01 reference actually uses.

Change:
- App.svelte: moved the height cap up to Sidebar.Provider itself
  (class="h-svh") instead of Sidebar.Inset, since the cap needs to sit
  above wherever the inset variant's margin gets applied, not on the
  same box as the margin.
- app.css: replaced the core tokens (background/foreground/card/
  popover/primary/secondary/muted/accent/border/input/ring/sidebar-*)
  with shadcn's canonical dark-theme OKLCH values (0-chroma neutral
  grays), pulled directly from huntabyte/shadcn-svelte's own
  docs/src/app.css rather than approximated. --success/--warning
  deliberately kept as real, distinguishable colors — they signal
  actual health state, and desaturating them to match the neutral
  chrome would reintroduce the "can't tell what's actually happening"
  problem this whole project started from (see 279549c). --accent-blue
  now aliases --sidebar-primary (still a real blue) instead of
  --primary, so the couple of spots wanting an interactive "pop" still
  have one while buttons/links/focus rings ride the neutral --primary.

Risk: reversible_low (UI-only).

Verification: npx tsc --noEmit clean (excluding pre-existing unrelated
.svelte type-resolution warnings). go build/vet clean (backend
untouched, sanity check only). Manually verified in the browser
preview at 1400px: document.body.scrollHeight now exactly matches
window.innerHeight on both Overview and the 193-row Entities table
(previously 6531px vs 900px); scrolled the Entities table wrapper to
row ~60 and confirmed the header/filter bar/column headers stay
pinned while only the table body scrolls; confirmed neutral gray
rendering across Overview's stat cards, the event-rate chart, and
Chat's tool-call list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 01:12:47 +02:00
cbfd09c5df feat: redesign toward shadcn-svelte dashboard-01 (inset sidebar, gradient stat cards)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Problem: requested visual alignment with the shadcn-svelte dashboard-01
reference block (shadcn-svelte.com/blocks#dashboard-01) — the app's
sidebar/header shell and Overview stat cards looked plain by comparison.

Change: pulled the actual reference source (app-sidebar.svelte,
nav-main.svelte, site-header.svelte, section-cards.svelte from
huntabyte/shadcn-svelte) rather than approximating from screenshots.

- App.svelte: Sidebar.Root now uses variant="inset" (the floating,
  rounded, shadowed content panel — already fully built into the
  existing Sidebar.Inset component via peer-data selectors, just never
  enabled). Brand mark is now a proper Sidebar.MenuButton matching the
  reference's padding/hover treatment; "New chat" uses the reference's
  primary-colored button styling. Header matches the reference exactly:
  h-(--header-height) (48px, was 44px), vertical separator after the
  sidebar trigger, right-aligned actions group.
- Overview.svelte: stat cards rebuilt to match section-cards.svelte —
  gradient background, Card.Action badge, Card.Footer with a bold line
  + muted context line, tabular-nums, responsive @container grid
  (1/2/4 columns). Deliberately did NOT copy the reference's fake
  trend-percentage badges (Oikos doesn't track historical trends, and
  this project's whole thrust has been eliminating dishonest UI state —
  see 279549c). Badges instead reflect real current-state signals
  (healthy/degraded/down, clear/needs-review) computed from the actual
  dashboard summary.
- EntityDetailContent.svelte + Entities/Signals/Ops/Events/Agent/
  Audit/Knowledge pages: normalized root padding to p-4 md:p-6 (was a
  flat p-6) to match the reference's responsive py-4 md:py-6 convention.

Risk: reversible_low (UI-only, no data or behavior changes).

Verification: npx tsc --noEmit clean (excluding pre-existing unrelated
.svelte type-resolution warnings, same as prior commits). go build/vet
clean (backend untouched, sanity check only). Manually verified in the
browser preview at 1400px: inset sidebar's margin/rounded-corner/shadow
classes confirmed applied via computed styles; Overview cards render
with real live numbers from the now-fixed dashboard summary endpoint;
Signals/Ops pages confirmed visually consistent with the new spacing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 00:54:51 +02:00
20 changed files with 941 additions and 242 deletions

View File

@@ -0,0 +1,160 @@
# 2026-07-09 — Chat sessions: reliability, cost, and session-management fixes
**Status:** Planned
## Goal
Fix concrete problems found by inspecting the *actual* production Nomos chat
data (`agent_sessions`/`agent_messages` on the `oikos` Postgres, 24 sessions /
52 messages as of 2026-07-09), not a code-review of the UI in the abstract.
Findings below are backed by real rows, not hypotheticals.
## How this was investigated
Queried `oikos-postgres-1` directly (`docker exec oikos-postgres-1 psql -U oikos
-d oikos`) since this runs on mac-mini, the same host as the production
containers. Pulled session list, per-message content sizes, tool-call
breakdowns, and cross-checked against `cmd/nomos/agent.go` to explain what was
observed.
---
## Findings
### 1. ~17% of turns come back completely empty, silently
4 of 24 sessions ("hi" ×3, "what services are healthy?") have an assistant
message with `text=""` and zero tool calls — the model returned a blank
completion. `cmd/nomos/agent.go:175-183` treats `len(msg.ToolCalls) == 0` as a
normal final answer and emits `text: ""` + `done`. No `error` event fires, so
[chat.ts](../web/src/lib/stores/chat.ts) never sets `$error`, and
[Chat.svelte](../web/src/pages/Chat.svelte) renders a permanently-blank
assistant bubble (the "…" typing dots only show while `$streaming` is true;
once `done` fires they vanish, leaving nothing). The user has no idea the turn
failed and no obvious way to retry — they have to notice the silence and
retype.
**Fix:** in `agent.chat()`, if `msg.Content == "" && len(msg.ToolCalls) == 0`,
treat it as a retryable failure: log it, retry once against the provider
before giving up, and if still empty, emit a real `error` event instead of an
empty `text`/`done` pair. On the frontend, surface a "Nomos didn't respond —
retry?" affordance on empty assistant messages rather than a silent blank
bubble.
### 2. A tool-heavy turn came back as a canned non-English refusal
The session "what are the termals of hubris?" ran 22 tool calls and then
returned, verbatim: `关于这个问题,我没有相关信息,您可以尝试问我其它问题,我会尽力为您解答~`
("I don't have relevant information on this, try asking me something else").
This is after successfully gathering data via tools — the model discarded its
own tool results and emitted a boilerplate deflection in the wrong language.
`NOMOS_MODEL` is currently a DeepSeek flash-tier model on OpenRouter, which is
consistent with this kind of degraded-tier fallback text leaking through.
**Fix:** add a response-quality guard in `agent.chat()` — if the final text
doesn't match the conversation's language/looks like a canned refusal (simple
heuristic: non-ASCII-majority reply to an ASCII-majority conversation, or
matches a small denylist of known refusal boilerplate), treat it like the
empty-response case (retry, then surface an error rather than showing it to
the operator as a real answer). Separately, reconsider whether the flash-tier
model is worth the latency/cost tradeoff given it's producing failures like
this in a small sample — worth an eval pass against a couple of alternative
OpenRouter models on the same 24 real prompts before deciding.
### 3. Simple fleet questions fan out into dozens of individual tool calls
"Are any of the proxmox hosts saturated?" (2 hosts) triggered **70 tool
calls** in one turn, 21 of them individual `get_lxc_state` calls — one per LXC
container — instead of using the already-available `list_lxcs()` bulk tool.
Similar pattern in "What should be updated with high priority?" (68 calls) and
"What needs updating?" (54 calls). Each `get_lxc_state` is a live `pct status`
SSH round-trip to the Proxmox host, so this is 21 sequential SSH round trips
to answer a question `list_lxcs()` already answers in one call. This is the
direct cause of both slow responses and the huge persisted payloads in
finding 4.
**Fix:** two angles, not mutually exclusive:
- **Prompt-level**: tighten the Nomos system prompt (`nomos/SOUL.md`) to
explicitly prefer bulk tools (`list_lxcs`, `get_state_snapshot`,
`query_metrics`) over per-entity tools when the question is fleet-wide, and
only fall back to `get_lxc_state`/`tail_log` for a specific named entity.
- **Tool-level**: `get_lxc_state` already exists per-slug; consider whether
`list_lxcs()`'s summary is actually sufficient for "saturated" (CPU/mem %
per container) — if it's missing that field, that's *why* the model loops
per-container, and the real fix is enriching `list_lxcs()` rather than
prompting around the gap.
### 4. Tool results are persisted raw and unbounded, inflating messages to 100KB+
Message content sizes in `agent_messages.content` (JSONB) range up to
**106KB** for a single assistant turn. Even a plain "hi" greeting produced a
44KB message, because `get_state_snapshot()`'s full result — every entity in
the DB, including ~15 `document:containers/*` rows that are all
`state: <nil>, health: unknown` and contribute nothing — gets embedded
verbatim in the `tool_calls[].result` field and stored as-is
(`cmd/nomos/store.go:68-76` just JSON-inserts whatever the tool returned).
This bloats the DB, and every time a session is opened via
[loadSessionMessages](../web/src/lib/stores/chat.ts#L56) or the
[SessionRail](../web/src/lib/components/SessionRail.svelte)/
[Sessions](../web/src/pages/Sessions.svelte) page loads history, the browser
downloads and parses all of it just to render a collapsed tool-call summary.
**Fix:**
- Filter `get_state_snapshot()`'s result server-side (in the MCP tool, not
the agent) to drop entities with no meaningful state/health signal, or add
a `type` filter param the agent can pass.
- In `store.saveMessage`, cap persisted tool-result size (e.g. truncate to a
few KB with a `"...truncated, N bytes"` marker) — the full result already
served its purpose informing that turn's answer; historical replay
(`agent.chat()`'s history-replay loop at `agent.go:122-137`) doesn't need
the full blob, just enough for the model to know what it already checked.
### 5. No session hygiene: duplicate/typo'd titles, no delete/archive
Session titles are the raw, unprocessed first user message
(`store.createSession`), with no dedup, normalization, or cleanup. Real
production titles include **6 sessions titled "hi"**, **2 titled "say ok"**,
and typos preserved verbatim ("what are the **termals** of hubris?", "whats
the **termans** of strong", "Are any of the **proxomox** hosts saturated?").
Neither [Sessions.svelte](../web/src/pages/Sessions.svelte) nor
[SessionRail.svelte](../web/src/lib/components/SessionRail.svelte) nor the
`store`/API layer (`cmd/nomos/store.go`, `web/src/lib/api.ts`) has any delete
or archive path — grepped the whole stack, confirmed absent. Throwaway test
sessions accumulate forever with no way to clean them from the UI.
**Fix:**
- Add `DELETE /sessions/{id}` to the nomos gateway + a matching store method
and wire a delete affordance into `SessionRail`/`Sessions` (hover trash
icon, confirm on click).
- Generate titles from the assistant's actual answer once the turn completes
(or a cheap follow-up summarization call) instead of the raw first message,
so distinct "hi" sessions become distinguishable by what was actually
discussed.
---
## Implementation order
1. **Empty-response + refusal-leak guard** (`cmd/nomos/agent.go`) — highest
user-visible impact, smallest change, no schema/API changes.
2. **Bulk-tool prompting fix** (`nomos/SOUL.md`) — cheap, directly cuts
latency and tool-call volume; re-run the same 24 real prompts against the
updated prompt to confirm `get_lxc_state` fan-out drops.
3. **Tool-result truncation on persist** (`cmd/nomos/store.go`) — bounds
future DB growth; pair with a one-off cleanup pass on the 52 existing rows
if the table needs to be shrunk immediately.
4. **`get_state_snapshot` filtering** — coordinate with whichever MCP tool
file defines it; verify with `jsonb_pretty` on a fresh "hi" session that
payload drops well below the current ~44KB.
5. **Session delete + title generation** — UI + gateway change, lowest risk,
can ship independently of 1-4.
## Verification
- Re-run the same 24 real user prompts (recorded in this plan's investigation)
against the patched agent; confirm zero empty/refusal-leak responses and
`get_lxc_state`-style fan-out drops to O(hosts) not O(containers).
- `docker exec oikos-postgres-1 psql -U oikos -d oikos -c "SELECT max(length(content::text)) FROM agent_messages;"`
before/after — expect the ceiling to move from ~106KB to low single-digit KB.
- Manually delete a test session via the new UI affordance, confirm it's gone
from both `SessionRail` and the `agent_sessions` table.

View File

@@ -13,6 +13,7 @@ went sideways, open an investigation.
| 2026-07-08 | [Oikos gaps, broken things, and improvements](2026-07-08-oikos-gaps-and-improvements.md) | Planned |
| 2026-07-08 | [Control room web UI](2026-07-08-control-room-webui.md) | In Progress |
| 2026-07-08 | [Nomos resident agent (renames Hermes)](2026-07-08-nomos-resident-agent.md) | In Progress |
| 2026-07-09 | [Chat sessions: reliability, cost, and session-management fixes](2026-07-09-chat-sessions-improvements.md) | Planned |
## Done

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="91" height="100" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path fill="#58a6ff" d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z"/>
<path fill="#ffffff" d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z"/>
</svg>

Before

Width:  |  Height:  |  Size: 666 B

After

Width:  |  Height:  |  Size: 666 B

View File

@@ -19,6 +19,7 @@
import * as Sheet from '$lib/components/ui/sheet'
import { Button } from '$lib/components/ui/button'
import { Badge } from '$lib/components/ui/badge'
import { Separator } from '$lib/components/ui/separator'
import { Toaster } from '$lib/components/ui/sonner'
import PlusIcon from '@lucide/svelte/icons/plus'
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
@@ -76,18 +77,34 @@
<Toaster />
<Sidebar.Provider>
<Sidebar.Root collapsible="icon">
<Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);">
<Sidebar.Root collapsible="icon" variant="inset">
<Sidebar.Header>
<div class="flex items-center px-1">
<svg viewBox="0 0 91 100" class="size-5 shrink-0 fill-white" aria-hidden="true" role="img">
<title>Oikos</title>
<path d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z" />
</svg>
</div>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton onclick={() => { newChat(); navigate('chat') }} tooltipContent="New chat">
<Sidebar.MenuButton
class="data-[slot=sidebar-menu-button]:!p-1.5"
onclick={() => navigate('overview')}
tooltipContent="Oikos"
>
{#snippet child({ props })}
<button {...props}>
<svg viewBox="0 0 91 100" class="!size-5 shrink-0 fill-white" aria-hidden="true" role="img">
<title>Oikos</title>
<path d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z" />
</svg>
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
class="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
onclick={() => { newChat(); navigate('chat') }}
tooltipContent="New chat"
>
{#snippet child({ props })}
<button {...props}>
<PlusIcon />
@@ -143,11 +160,12 @@
</Sidebar.Footer>
</Sidebar.Root>
<Sidebar.Inset class="h-svh min-h-0">
<header class="flex h-11 shrink-0 items-center gap-3 border-b px-3">
<Sidebar.Trigger />
<span class="text-sm font-medium capitalize">{page === 'entity' ? routeParam : page}</span>
<div class="flex-1"></div>
<Sidebar.Inset class="min-h-0 overflow-hidden">
<header class="flex h-(--header-height) shrink-0 items-center gap-1 border-b px-4 lg:gap-2 lg:px-6">
<Sidebar.Trigger class="-ms-1" />
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page}</span>
<div class="ms-auto flex items-center gap-2.5">
{#if $summary}
<div class="hidden items-center gap-2.5 text-xs text-muted-foreground sm:flex">
<span class="flex items-center gap-1" title="healthy"><span class="size-2 rounded-full bg-success"></span>{$summary.health.healthy}</span>
@@ -169,6 +187,7 @@
class="size-2 rounded-full {$connectionState === 'open' ? 'bg-success' : $connectionState === 'connecting' ? 'animate-pulse bg-warning' : 'bg-destructive'}"
title="event stream: {$connectionState}"
></span>
</div>
</header>
<main class="min-h-0 flex-1 overflow-hidden">
{#if page === 'overview'}

View File

@@ -2,59 +2,61 @@
@custom-variant dark (&:is(.dark *));
/* Neutral gray theme matching shadcn/ui's canonical dark palette (0-chroma
OKLCH grays) — the app is dark-only, so :root carries the dark values
directly rather than gating behind a .dark class. --success/--warning are
Oikos-specific semantic status colors (real health state), kept
distinguishable rather than desaturated to match the neutral chrome. */
:root {
--radius: 0.5rem;
--background: #0d1117;
--foreground: #e6edf3;
--card: #161b22;
--card-foreground: #e6edf3;
--popover: #161b22;
--popover-foreground: #e6edf3;
--primary: #58a6ff;
--primary-foreground: #0d1117;
--secondary: #21262d;
--secondary-foreground: #e6edf3;
--muted: #21262d;
--muted-foreground: #8b949e;
--accent: #292e36;
--accent-foreground: #e6edf3;
--destructive: #f85149;
--destructive-foreground: #ffffff;
--radius: 0.625rem;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--success: #3fb950;
--warning: #d29922;
--border: #30363d;
--input: #30363d;
--ring: #58a6ff;
--sidebar: #161b22;
--sidebar-foreground: #e6edf3;
--sidebar-primary: #58a6ff;
--sidebar-primary-foreground: #0d1117;
--sidebar-accent: #21262d;
--sidebar-accent-foreground: #e6edf3;
--sidebar-border: #30363d;
--sidebar-ring: #58a6ff;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
/* legacy aliases still referenced by Chat/Sessions/App */
--bg: var(--background);
--bg-surface: var(--card);
--bg-deeper: #0a0e13;
--bg-deeper: oklch(0.11 0 0);
--bg-hover: var(--secondary);
--bg-active: var(--accent);
--text: var(--foreground);
--text-muted: var(--muted-foreground);
--accent-blue: var(--primary);
/* accent-blue stays a real blue (matches --sidebar-primary) for the few
spots that want an interactive "pop" — everything else (buttons,
links, focus rings) rides the neutral --primary now. */
--accent-blue: var(--sidebar-primary);
--accent-green: var(--success);
--accent-red: var(--destructive);
--accent-orange: var(--warning);
}
/* the app is dark-only; treat root as the dark theme unconditionally */
.dark {
--background: #0d1117;
--foreground: #e6edf3;
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@@ -105,6 +107,16 @@
line-height: 1.4;
-webkit-font-smoothing: antialiased;
}
/* Tailwind's preflight resets <button> to cursor: default; every button
and native interactive element in this app is clickable, so restore
the pointer cursor app-wide instead of annotating each one. */
button:not(:disabled),
[role='button']:not([aria-disabled='true']),
a[href],
summary,
select {
cursor: pointer;
}
}
#app {

View File

@@ -1,109 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte'
import { summary, pendingApprovals, subscribeContext, refreshContext } from '$lib/stores/context'
import { liveEvents } from '$lib/stores/events'
import { decideApproval } from '$lib/api'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
import { Separator } from '$lib/components/ui/separator'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import { toast } from 'svelte-sonner'
let deciding = $state<string | null>(null)
onMount(() => subscribeContext())
async function decide(id: string, decision: 'approve' | 'deny') {
deciding = id
const result = await decideApproval(id, decision)
deciding = null
if (result) {
toast.success(`Approval ${decision === 'approve' ? 'approved' : 'denied'}`)
refreshContext()
} else {
toast.error('Decision failed')
}
}
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
if (sev === 'critical') return 'destructive'
if (sev === 'warning') return 'secondary'
return 'default'
}
const recentEvents = $derived($liveEvents.slice(0, 10))
</script>
<aside class="flex h-full w-72 shrink-0 flex-col gap-3 overflow-y-auto border-l bg-card/50 p-3">
{#if $summary}
<div>
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Fleet health</p>
<div class="flex items-center gap-3 text-xs">
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-success"></span>{$summary.health.healthy}</span>
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-warning"></span>{$summary.health.degraded}</span>
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-destructive"></span>{$summary.health.down}</span>
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-muted-foreground"></span>{$summary.health.unknown}</span>
</div>
</div>
<Separator />
{/if}
<div>
<div class="mb-1.5 flex items-center justify-between">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Pending approvals</p>
{#if $pendingApprovals.length}
<Badge variant="destructive" class="h-4 px-1.5 text-[10px]">{$pendingApprovals.length}</Badge>
{/if}
</div>
<div class="flex flex-col gap-2">
{#each $pendingApprovals.slice(0, 5) as approval (approval.id)}
<div class="rounded-md border bg-background p-2">
<p class="truncate font-mono text-[11px]">{approval.subject ?? approval.slug}</p>
<p class="mb-1.5 text-xs">{approval.action} <Badge variant="outline" class="ml-1 h-4 px-1 text-[10px]">{approval.risk_class}</Badge></p>
<div class="flex gap-1.5">
<Button size="sm" class="h-6 flex-1 text-xs" disabled={deciding === approval.id} onclick={() => decide(approval.id, 'approve')}>Approve</Button>
<Button size="sm" variant="destructive" class="h-6 flex-1 text-xs" disabled={deciding === approval.id} onclick={() => decide(approval.id, 'deny')}>Deny</Button>
</div>
</div>
{:else}
<p class="text-xs text-muted-foreground">Nothing waiting on you.</p>
{/each}
{#if $pendingApprovals.length > 5}
<button type="button" class="text-left text-xs text-primary hover:underline" onclick={() => (location.hash = '#/ops')}>
+{$pendingApprovals.length - 5} more in Operations
</button>
{/if}
</div>
</div>
{#if $summary && Object.keys($summary.signals_by_severity).length}
<Separator />
<div>
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Open signals</p>
<div class="flex flex-wrap gap-1">
{#each Object.entries($summary.signals_by_severity) as [severity, count]}
<button type="button" onclick={() => (location.hash = '#/signals')}>
<Badge variant={severityVariant(severity)} class="cursor-pointer">{severity}: {count}</Badge>
</button>
{/each}
</div>
</div>
{/if}
<Separator />
<div class="flex min-h-0 flex-1 flex-col">
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Live events</p>
<ScrollArea class="min-h-0 flex-1">
<div class="flex flex-col gap-1.5 pr-2">
{#each recentEvents as ev (ev.id)}
<div class="text-[11px] leading-tight">
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleTimeString()}</span>
<span class="ml-1 {ev.severity === 'critical' ? 'text-destructive' : ev.severity === 'warning' ? 'text-warning' : ''}">{ev.type}</span>
</div>
{:else}
<p class="text-xs text-muted-foreground">Quiet for now.</p>
{/each}
</div>
</ScrollArea>
</div>
</aside>

View File

@@ -124,7 +124,7 @@
}
</script>
<div class="@container flex h-full flex-col gap-4 overflow-y-auto p-6">
<div class="@container flex h-full flex-col gap-4 overflow-y-auto p-4 md:p-6">
{#if loading}
<Skeleton class="h-8 w-48" />
<div class="grid grid-cols-1 gap-4 @lg:grid-cols-2">

View File

@@ -0,0 +1,441 @@
<script lang="ts">
import { onDestroy, untrack } from 'svelte'
import {
forceSimulation,
forceLink,
forceManyBody,
forceCenter,
forceCollide,
forceX,
forceY,
type Simulation
} from 'd3-force'
import { fetchGraph, type Entity } from '$lib/api'
import { messages } from '$lib/stores/chat'
import { relativeTime } from '$lib/utils'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
import EntitySheet from '$lib/components/EntitySheet.svelte'
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
interface Node extends Entity {
x?: number
y?: number
vx?: number
vy?: number
fx?: number | null
fy?: number | null
degree: number
}
interface Edge {
source: string | Node
target: string | Node
type: string
}
// Probe/bookkeeping entity types are excluded — a health conversation
// mentions dozens of check:… slugs that would swamp the fleet topology.
const EXCLUDED = new Set(['check', 'execution'])
// Slug shape: lowercase type prefix, then one or more colon-separated
// segments (host:hubris, check:ping:8cf, lxc:caddy, investigation:foo/bar).
const SLUG_RE = /\b[a-z][a-z-]*:[a-z0-9][a-z0-9._/-]*(?::[a-z0-9._/-]+)*/g
let nodes = $state<Node[]>([])
let links = $state<Edge[]>([])
let selected = $state<Node | null>(null)
let sheetSlug = $state<string | null>(null)
let sheetOpen = $state(false)
let sim: Simulation<Node, Edge> | null = null
// Non-reactive caches (persist across message deltas). resolvedVersion is a
// reactive counter bumped when async resolution finishes, so the reconcile
// effect re-runs once entities come back.
const resolvedCache = new Map<string, Node | null>()
const edgeCache: { source: string; target: string; type: string }[] = []
const edgeKeys = new Set<string>()
const resolving = new Set<string>()
let resolvedVersion = $state(0)
// container size drives the simulation coordinate space (1:1 with pixels so
// node dragging maps cleanly regardless of the resizable panel width).
let container = $state<HTMLDivElement | null>(null)
let cw = $state(300)
let ch = $state(300)
function collectSlugs(value: unknown, out: Set<string>) {
if (typeof value === 'string') {
const m = value.match(SLUG_RE)
if (m) for (const s of m) out.add(s.replace(/[.,;)\]]+$/, ''))
} else if (Array.isArray(value)) {
for (const v of value) collectSlugs(v, out)
} else if (value && typeof value === 'object') {
for (const v of Object.values(value)) collectSlugs(v, out)
}
}
// Only pull from what the conversation is *about*: message text and the
// arguments the agent passed to tools — never bulk result rows (a single
// get_health_summary would otherwise dump all 168 entities into the graph).
const candidateSlugs = $derived.by(() => {
const out = new Set<string>()
for (const m of $messages) {
collectSlugs(m.text, out)
for (const t of m.tools) collectSlugs(t.args, out)
}
return out
})
async function resolveSlugs(slugs: string[]) {
const todo = slugs.filter((s) => !resolvedCache.has(s) && !resolving.has(s))
if (!todo.length) return
for (const s of todo) resolving.add(s)
await Promise.all(
todo.map(async (s) => {
try {
const g = await fetchGraph({ root: s, depth: 1 })
const root = g?.nodes.find((n) => n.slug === s) ?? null
resolvedCache.set(s, root ? { ...root, degree: 0 } : null)
if (g && root) {
for (const e of g.edges) {
const k = `${e.source}|${e.target}|${e.type}`
if (!edgeKeys.has(k)) {
edgeKeys.add(k)
edgeCache.push({ source: e.source, target: e.target, type: e.type })
}
}
}
} catch {
resolvedCache.set(s, null)
} finally {
resolving.delete(s)
}
})
)
resolvedVersion++
}
function reconcile(cands: Set<string>) {
const desired: Node[] = []
const seen = new Set<string>()
for (const s of cands) {
const e = resolvedCache.get(s)
if (e && !EXCLUDED.has(e.type) && !seen.has(e.slug)) {
seen.add(e.slug)
desired.push(e)
}
}
const desiredSlugs = new Set(desired.map((e) => e.slug))
const current = nodes
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) return
const bySlug = new Map(current.map((n) => [n.slug, n]))
const ls = edgeCache
.filter((e) => desiredSlugs.has(e.source) && desiredSlugs.has(e.target))
.map((e) => ({ ...e }))
const deg = new Map<string, number>()
for (const l of ls) {
deg.set(l.source as string, (deg.get(l.source as string) ?? 0) + 1)
deg.set(l.target as string, (deg.get(l.target as string) ?? 0) + 1)
}
const next = desired.map((e) => {
const p = bySlug.get(e.slug)
return { ...e, x: p?.x, y: p?.y, vx: p?.vx, vy: p?.vy, degree: deg.get(e.slug) ?? 0 }
})
nodes = next
links = ls
if (selected && !desiredSlugs.has(selected.slug)) selected = null
buildSim()
}
$effect(() => {
const cands = candidateSlugs
void resolvedVersion
const missing = [...cands].filter((s) => !resolvedCache.has(s))
if (missing.length) resolveSlugs(missing)
untrack(() => reconcile(cands))
})
function buildSim() {
sim?.stop()
if (!nodes.length) {
sim = null
return
}
sim = forceSimulation(nodes)
.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('x', forceX(cw / 2).strength(0.06))
.force('y', forceY(ch / 2).strength(0.06))
.velocityDecay(0.34)
.alphaDecay(0.045)
.on('tick', () => {
nodes = [...nodes]
})
}
// keep the layout centred as the panel resizes
$effect(() => {
const w = cw
const h = ch
if (sim) {
sim.force('center', forceCenter(w / 2, h / 2))
sim.force('x', forceX(w / 2).strength(0.06))
sim.force('y', forceY(h / 2).strength(0.06))
sim.alpha(0.3).restart()
}
})
$effect(() => {
if (!container) return
const ro = new ResizeObserver((entries) => {
const r = entries[0].contentRect
cw = Math.max(r.width, 1)
ch = Math.max(r.height, 1)
})
ro.observe(container)
return () => ro.disconnect()
})
onDestroy(() => sim?.stop())
const healthColor: Record<string, string> = {
healthy: 'var(--success)',
degraded: 'var(--warning)',
down: 'var(--destructive)',
stale: 'var(--warning)',
unknown: 'var(--muted-foreground)'
}
function nodeColor(n: Node): string {
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)
}
function shortName(slug: string): string {
return slug.split(':').pop() ?? slug
}
function endpoint(end: string | Node): Node | undefined {
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
}
function endpointSlug(end: string | Node): string {
return typeof end === 'object' ? end.slug : end
}
// ─── drag / select ───────────────────────────────────────────────────
let dragState: { node: Node; moved: boolean } | null = null
function toLocal(clientX: number, clientY: number) {
const rect = container!.getBoundingClientRect()
return { x: clientX - rect.left, y: clientY - rect.top }
}
function onNodeDown(e: PointerEvent, node: Node) {
e.stopPropagation()
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
dragState = { node, moved: false }
sim?.alphaTarget(0.2).restart()
}
function onMove(e: PointerEvent) {
if (!dragState) return
const p = toLocal(e.clientX, e.clientY)
dragState.node.fx = p.x
dragState.node.fy = p.y
dragState.moved = true
nodes = [...nodes]
}
function onUp() {
if (!dragState) return
const { node, moved } = dragState
node.fx = null
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) selected = selected?.slug === node.slug ? null : node
}
const selectedRelations = $derived(
selected
? links
.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) }
})
: []
)
function openFull() {
if (!selected) return
sheetSlug = selected.slug
sheetOpen = true
}
</script>
<aside class="flex h-full min-h-0 flex-col bg-card/40">
<div class="flex shrink-0 items-center justify-between border-b px-3 py-2">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Session graph</p>
{#if nodes.length}
<span class="text-[11px] text-muted-foreground">{nodes.length} {nodes.length === 1 ? 'entity' : 'entities'}</span>
{/if}
</div>
<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">
<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" />
</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>
</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>
</g>
</svg>
<p class="max-w-[16rem] text-xs leading-relaxed text-muted-foreground">
Entities Nomos explores in this conversation appear here, wired up by their relationships.
</p>
</div>
{:else}
<svg
width={cw}
height={ch}
viewBox="0 0 {cw} {ch}"
class="h-full w-full touch-none select-none"
role="application"
aria-label="Session entity graph"
onpointermove={onMove}
onpointerup={onUp}
onpointercancel={onUp}
>
<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}
{@const focus = selected && (s.slug === selected.slug || t.slug === selected.slug)}
<line
x1={s.x}
y1={s.y}
x2={t.x}
y2={t.y}
stroke="var(--muted-foreground)"
stroke-width={focus ? 1.6 : 1}
opacity={selected ? (focus ? 0.7 : 0.12) : 0.35}
>
<title>{link.type}</title>
</line>
{/if}
{/each}
</g>
<g>
{#each nodes as node (node.slug)}
{#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)}
<g
transform="translate({node.x},{node.y})"
class="cursor-pointer"
opacity={dim ? 0.35 : 1}
role="button"
tabindex="0"
onpointerdown={(e) => onNodeDown(e, node)}
onkeydown={(e) => e.key === 'Enter' && (selected = node)}
>
{#if isSel}
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
{/if}
<circle r={r} fill={nodeColor(node)} stroke={isSel ? 'var(--foreground)' : 'var(--background)'} stroke-width={isSel ? 2 : 1.5} />
<text
y={r + 10}
text-anchor="middle"
font-size="9"
fill={isSel ? 'var(--foreground)' : 'var(--muted-foreground)'}
paint-order="stroke"
stroke="var(--background)"
stroke-width="2.5"
class="pointer-events-none"
>
{shortName(node.slug)}
</text>
</g>
{/if}
{/each}
</g>
</svg>
{/if}
</div>
{#if selected}
<div class="max-h-[55%] shrink-0 space-y-3 overflow-y-auto border-t p-3 text-xs">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-mono text-sm font-semibold">{selected.slug}</span>
<Badge variant="outline">{selected.type}</Badge>
{#if selected.state}<Badge variant="secondary">{selected.state}</Badge>{/if}
</div>
{#if selected.health}
<div class="flex items-center gap-1.5 text-muted-foreground">
<span class="size-2 rounded-full" style="background: {nodeColor(selected)}"></span>
{selected.health} · checked {relativeTime(selected.last_check_at)}
</div>
{/if}
{#if selected.attributes && Object.keys(selected.attributes).length}
<div>
<p class="mb-1 font-medium text-muted-foreground">Attributes</p>
<dl class="flex flex-col gap-1">
{#each Object.entries(selected.attributes).slice(0, 6) as [key, value]}
<div class="flex items-start justify-between gap-3 border-b pb-1 last:border-0">
<dt class="shrink-0 font-mono text-muted-foreground">{key}</dt>
<dd class="min-w-0 flex-1 truncate text-right">{typeof value === 'object' ? JSON.stringify(value) : String(value)}</dd>
</div>
{/each}
</dl>
</div>
{/if}
{#if selectedRelations.length}
<div>
<p class="mb-1 font-medium text-muted-foreground">Relations ({selectedRelations.length})</p>
<div class="flex flex-col gap-1">
{#each selectedRelations as rel}
<div class="flex items-center gap-1 font-mono">
<span class="text-muted-foreground">{rel.dir} {rel.type}</span>
<button type="button" class="truncate hover:underline" onclick={() => { const n = nodes.find((x) => x.slug === rel.other); if (n) selected = n }}>
{rel.other}
</button>
</div>
{/each}
</div>
</div>
{/if}
<Button variant="outline" size="sm" class="w-full" onclick={openFull}>
<ExternalLinkIcon class="mr-1 size-3.5" />
Full detail
</Button>
</div>
{/if}
</aside>
<EntitySheet slug={sheetSlug} bind:open={sheetOpen} />

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import WrenchIcon from '@lucide/svelte/icons/wrench'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
// active = this message is the one currently streaming a round of tool
// calls. The group starts open while active (so progress is visible live)
// and auto-collapses the moment that round finishes; a loaded/historical
// message is never active, so it starts collapsed. Once the effect below
// fires the one-time auto-collapse, manual toggles are left alone.
let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props()
let open = $state(active)
let wasActive = active
$effect(() => {
if (wasActive && !active) {
open = false
}
wasActive = active
})
const doneCount = $derived(tools.filter((t) => t.type === 'tool_result').length)
const hasError = $derived(tools.some((t) => t.type === 'tool_result' && t.error))
const inProgress = $derived(active && doneCount < tools.length)
const names = $derived(tools.map((t) => t.name).join(', '))
function toolSummary(args: unknown): string {
if (!args || typeof args !== 'object') return ''
return Object.entries(args as Record<string, unknown>)
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join(' ')
.slice(0, 80)
}
</script>
{#if tools.length}
<details bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
<summary class="flex cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">
{#if inProgress}
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
{:else if hasError}
<XIcon class="size-3 shrink-0 text-destructive" />
{:else}
<CheckIcon class="size-3 shrink-0 text-success" />
{/if}
<span class="font-medium">{tools.length} tool{tools.length === 1 ? '' : 's'}</span>
<span class="max-w-64 truncate font-mono text-muted-foreground">{names}</span>
<ChevronDownIcon class="size-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
</summary>
<div class="flex flex-col divide-y border-t">
{#each tools as tool (tool.id)}
<div class="p-2">
<div class="flex items-center gap-2">
{#if tool.type === 'tool_result' && tool.error}
<XIcon class="size-3 shrink-0 text-destructive" />
{:else if tool.type === 'tool_result'}
<CheckIcon class="size-3 shrink-0 text-success" />
{:else}
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
{/if}
<span class="font-mono font-medium">{tool.name}</span>
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
</div>
<div class="mt-1 max-h-48 overflow-y-auto rounded bg-background/60 p-2">
{#if tool.args}
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{JSON.stringify(tool.args, null, 2)}</pre>
{/if}
{#if tool.type === 'tool_result'}
<pre class="mt-1 whitespace-pre-wrap break-all font-mono text-[11px] {tool.error ? 'text-destructive' : ''}">{tool.error ?? JSON.stringify(tool.result, null, 2)}</pre>
{/if}
</div>
</div>
{/each}
</div>
</details>
{/if}

View File

@@ -61,7 +61,10 @@
"data-slot": "sidebar-menu-button",
"data-sidebar": "menu-button",
"data-size": size,
"data-active": isActive,
// Tailwind's bare `data-active:` variant matches attribute *presence*,
// not its value — omit the attribute entirely when false instead of
// rendering data-active="false" (which the variant still matches).
"data-active": isActive || undefined,
...restProps,
});
</script>

View File

@@ -25,7 +25,10 @@
"data-slot": "sidebar-menu-sub-button",
"data-sidebar": "menu-sub-button",
"data-size": size,
"data-active": isActive,
// Tailwind's bare `data-active:` variant matches attribute *presence*,
// not its value — omit the attribute entirely when false instead of
// rendering data-active="false" (which the variant still matches).
"data-active": isActive || undefined,
...restProps,
});
</script>

View File

@@ -49,7 +49,7 @@
}
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Agent activity</h1>
<span class="text-xs text-muted-foreground">{activities.length} entries</span>

View File

@@ -49,7 +49,7 @@
}
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Audit trail</h1>
<span class="text-xs text-muted-foreground">{entries.length} entries</span>

View File

@@ -1,14 +1,12 @@
<script lang="ts">
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
import ContextRail from '$lib/components/ContextRail.svelte'
import SessionRail from '$lib/components/SessionRail.svelte'
import SessionGraph from '$lib/components/SessionGraph.svelte'
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import SquareIcon from '@lucide/svelte/icons/square'
import WrenchIcon from '@lucide/svelte/icons/wrench'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
@@ -17,6 +15,35 @@
let input = $state('')
let messagesEnd = $state<HTMLDivElement | null>(null)
// Resizable right rail (session graph). Persisted so it survives reloads.
const RAIL_MIN = 260
const RAIL_MAX = 620
function loadRailWidth(): number {
if (typeof localStorage === 'undefined') return 320
const v = Number(localStorage.getItem('oikos-rail-width'))
return v >= RAIL_MIN && v <= RAIL_MAX ? v : 320
}
let railWidth = $state(loadRailWidth())
let resizing = $state(false)
function startResize(e: PointerEvent) {
e.preventDefault()
resizing = true
const startX = e.clientX
const startW = railWidth
function move(ev: PointerEvent) {
railWidth = Math.min(RAIL_MAX, Math.max(RAIL_MIN, startW + (startX - ev.clientX)))
}
function up() {
resizing = false
localStorage.setItem('oikos-rail-width', String(railWidth))
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
$effect(() => {
void $messages
void $streaming
@@ -52,14 +79,6 @@
if ($streaming) return
sendMessage(q)
}
function toolSummary(args: unknown): string {
if (!args || typeof args !== 'object') return ''
return Object.entries(args as Record<string, unknown>)
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
.join(' ')
.slice(0, 80)
}
</script>
<div class="flex h-full min-h-0">
@@ -87,35 +106,13 @@
</div>
{/if}
{#each $messages as msg (msg.id)}
{#each $messages as msg, i (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">{msg.text}</div>
{:else}
<div class="flex w-full flex-col gap-2">
{#each msg.tools as tool (tool.id)}
<details class="w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
<summary class="flex cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">
{#if tool.type === 'tool_result' && tool.error}
<XIcon class="size-3 shrink-0 text-destructive" />
{:else if tool.type === 'tool_result'}
<CheckIcon class="size-3 shrink-0 text-success" />
{:else}
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
{/if}
<span class="font-mono font-medium">{tool.name}</span>
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
</summary>
<div class="max-h-48 overflow-y-auto border-t bg-background/60 p-2">
{#if tool.args}
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{JSON.stringify(tool.args, null, 2)}</pre>
{/if}
{#if tool.type === 'tool_result'}
<pre class="mt-1 whitespace-pre-wrap break-all font-mono text-[11px] {tool.error ? 'text-destructive' : ''}">{tool.error ?? JSON.stringify(tool.result, null, 2)}</pre>
{/if}
</div>
</details>
{/each}
<ToolCallGroup tools={msg.tools} active={$streaming && i === $messages.length - 1} />
{#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
@@ -174,8 +171,22 @@
</div>
{#if showRail}
<div class="hidden xl:block">
<ContextRail />
<div class="hidden shrink-0 xl:flex" style="width: {railWidth}px">
<button
type="button"
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
onpointerdown={startResize}
aria-label="Resize session graph"
>
<span
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
? 'bg-primary/60'
: 'bg-border group-hover/rz:bg-primary/50'}"
></span>
</button>
<div class="min-w-0 flex-1">
<SessionGraph />
</div>
</div>
{/if}
</div>

View File

@@ -73,7 +73,7 @@
}
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Entities</h1>
<span class="text-xs text-muted-foreground">{filtered.length} of {entities.length}</span>

View File

@@ -80,7 +80,7 @@
}
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Live event feed</h1>
<span class="text-xs text-muted-foreground">

View File

@@ -27,7 +27,7 @@
}
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<h1 class="text-lg font-semibold">Knowledge search</h1>
<form

View File

@@ -79,7 +79,7 @@
const decidedApprovals = $derived(approvals.filter((a) => a.status !== 'pending'))
</script>
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<h1 class="text-lg font-semibold">Operations ledger</h1>
<Tabs.Root value="approvals" class="flex flex-1 flex-col overflow-hidden">

View File

@@ -6,6 +6,9 @@
import { Badge } from '$lib/components/ui/badge'
import { Skeleton } from '$lib/components/ui/skeleton'
import { ScrollArea } from '$lib/components/ui/scroll-area'
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert'
import OctagonXIcon from '@lucide/svelte/icons/octagon-x'
let summary = $state<DashboardSummary | null>(null)
let loading = $state(true)
@@ -47,9 +50,44 @@
function formatEventLabel(ev: OikosEvent) {
return ev.type
}
const totalEntities = $derived(
summary ? Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0) : 0
)
const topTypes = $derived(
summary
? Object.entries(summary.entities_by_type)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
: []
)
const entityTypeCount = $derived(summary ? Object.keys(summary.entities_by_type).length : 0)
const totalMonitored = $derived(
summary
? summary.health.healthy + summary.health.degraded + summary.health.down + summary.health.unknown
: 0
)
const healthTone = $derived(
!summary ? 'ok' : summary.health.down > 0 ? 'down' : summary.health.degraded > 0 ? 'degraded' : 'ok'
)
const totalSignals = $derived(
summary ? Object.values(summary.signals_by_severity).reduce((a, b) => a + b, 0) : 0
)
const worstSeverity = $derived(
summary?.signals_by_severity.critical
? 'critical'
: summary?.signals_by_severity.warning
? 'warning'
: 'none'
)
const executionsRunning = $derived(summary?.executions_by_state.running ?? 0)
const executionsFailed = $derived(summary?.executions_by_state.failed ?? 0)
</script>
<div class="flex h-full flex-col gap-4 overflow-y-auto p-6">
<div class="@container/main flex h-full flex-col gap-4 overflow-y-auto p-4 md:p-6">
<h1 class="text-lg font-semibold">Overview</h1>
{#if loading}
@@ -59,58 +97,99 @@
{/each}
</div>
{:else if summary}
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card.Root>
<div
class="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs @xl/main:grid-cols-2 @5xl/main:grid-cols-4"
>
<Card.Root class="@container/card">
<Card.Header>
<Card.Description>Entities</Card.Description>
<Card.Title class="text-2xl">
{Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0)}
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{totalEntities}
</Card.Title>
<Card.Action>
<Badge variant="outline">{entityTypeCount} types</Badge>
</Card.Action>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs text-muted-foreground">
{#each Object.entries(summary.entities_by_type) as [type, count]}
<Badge variant="outline">{type}: {count}</Badge>
{/each}
</Card.Content>
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex flex-wrap gap-x-1.5 font-medium">
{#each topTypes as [type, count]}
<span class="text-muted-foreground">{type}: <span class="text-foreground">{count}</span></span>
{/each}
</div>
<div class="text-muted-foreground">Across the fleet</div>
</Card.Footer>
</Card.Root>
<Card.Root>
<Card.Root class="@container/card">
<Card.Header>
<Card.Description>Health</Card.Description>
<Card.Title class="text-2xl">{summary.health.healthy} healthy</Card.Title>
<Card.Description>Fleet health</Card.Description>
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{summary.health.healthy} / {totalMonitored}
</Card.Title>
<Card.Action>
{#if healthTone === 'ok'}
<Badge variant="outline"><CircleCheckIcon class="text-success" />healthy</Badge>
{:else if healthTone === 'degraded'}
<Badge variant="outline"><TriangleAlertIcon class="text-warning" />degraded</Badge>
{:else}
<Badge variant="outline"><OctagonXIcon class="text-destructive" />down</Badge>
{/if}
</Card.Action>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs">
<Badge>healthy: {summary.health.healthy}</Badge>
<Badge variant="secondary">degraded: {summary.health.degraded}</Badge>
<Badge variant="destructive">down: {summary.health.down}</Badge>
<Badge variant="outline">unknown: {summary.health.unknown}</Badge>
</Card.Content>
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex gap-2 font-medium">
{summary.health.degraded} degraded · {summary.health.down} down · {summary.health.unknown} unmonitored
</div>
<div class="text-muted-foreground">Healthy entities as observed by the scheduler</div>
</Card.Footer>
</Card.Root>
<Card.Root>
<Card.Root class="@container/card">
<Card.Header>
<Card.Description>Open signals</Card.Description>
<Card.Title class="text-2xl">
{Object.values(summary.signals_by_severity).reduce((a, b) => a + b, 0)}
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{totalSignals}
</Card.Title>
<Card.Action>
{#if worstSeverity === 'critical'}
<Badge variant="destructive"><TriangleAlertIcon />critical</Badge>
{:else if worstSeverity === 'warning'}
<Badge variant="secondary"><TriangleAlertIcon />warning</Badge>
{:else}
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
{/if}
</Card.Action>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs">
{#each Object.entries(summary.signals_by_severity) as [severity, count]}
<Badge variant={severityVariant(severity)}>{severity}: {count}</Badge>
{/each}
</Card.Content>
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex flex-wrap gap-x-1.5 font-medium">
{#each Object.entries(summary.signals_by_severity) as [severity, count]}
<span class="text-muted-foreground">{severity}: <span class="text-foreground">{count}</span></span>
{/each}
</div>
<div class="text-muted-foreground">Unresolved right now</div>
</Card.Footer>
</Card.Root>
<Card.Root>
<Card.Root class="@container/card">
<Card.Header>
<Card.Description>Pending approvals</Card.Description>
<Card.Title class="text-2xl">{summary.approvals_pending}</Card.Title>
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{summary.approvals_pending}
</Card.Title>
<Card.Action>
{#if summary.approvals_pending > 0}
<Badge variant="destructive">needs review</Badge>
{:else}
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
{/if}
</Card.Action>
</Card.Header>
<Card.Content class="flex flex-wrap gap-1 text-xs">
{#each Object.entries(summary.executions_by_state) as [state, count]}
<Badge variant="outline">{state}: {count}</Badge>
{/each}
</Card.Content>
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex gap-2 font-medium">
{executionsRunning} running · {executionsFailed} failed
</div>
<div class="text-muted-foreground">Executions in the last 24h</div>
</Card.Footer>
</Card.Root>
</div>

View File

@@ -134,7 +134,7 @@
</div>
{/snippet}
<div class="flex h-full flex-col gap-4 p-6">
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold">Signals</h1>
<Select.Root type="single" bind:value={severityFilter}>