The window rendered the same 13 collapsible sections for every entity, sorted
only by "does it have content". Audit trail carried the same visual weight as
Health, and the window answered "what data do we hold about X?" rather than
"what do I need to know, and what should I do?".
Measured against prod: host:hubris has 223 relations, 1,601 events, 2.7M metric
samples and 148 executions; an ingress route has three facts. Both got 13
identical headers. Expanding a host put ~540 interactive elements on screen.
- **A verdict header that never collapses.** Not just "down" but *why*:
"ping failing · 5 of 6 checks passing". That line did not previously exist
and could not have — checks rendered as configuration, never as results.
- **Sections composed per type.** A document has no checks, metrics or blast
radius; a signal or execution is a record, not a thing. Infrastructure gets
Status/Impact/Activity/Metrics/Reference, knowledge types lead with Content,
records get a minimal view. Unknown types fall back to infrastructure so a
new entity type is never a blank window.
- **Status replaces Monitoring**, showing each check's own verdict and when it
last ran — the section that answers the header's "why".
- **Impact** finally calls /entities/{id}/blast-radius. The endpoint has existed
since the first API and had no frontend caller anywhere, despite
.agents/OIKOS.md naming blast radius as the reason the ontology exists. Its
outgoing-edges-only limitation is stated in the UI rather than hidden.
- **Activity merges four lists** (executions, signals, events, agent activity)
that were telling one story in four places.
- **Relations cap at 8 with a drill-in** — 540 interactive elements down to 126.
- **Ask Nomos** opens a task pre-scoped to what you are looking at, seeded with
the verdict just computed, via an optional draft threaded through
openNewTaskWindow -> NewTaskChat -> ChatThread.
Requires exposing check_defs.last_health/last_run_at through the API (the
columns landed with the health-aggregation work but were never surfaced).
Adding a fourth enum containing "unknown" made oapi-codegen disambiguate all
enum constants by type prefix, so metrics.go moves to gen.TrendDirection*.
Verdict derivation and type->section composition live in $lib/entityView.ts as
pure functions with 15 unit tests, including the host:strong case that
motivated this.
Co-Authored-By: Claude <noreply@anthropic.com>
198 lines
8.2 KiB
TypeScript
198 lines
8.2 KiB
TypeScript
// One global wmkit window manager for the whole app (mounted once by
|
|
// WindowLayer.svelte inside Desktop.svelte) — this is what lets an entity
|
|
// opened from Knowledge Base, chat, or anywhere else land in the same
|
|
// floating window layer, with several windows open side by side, rather than
|
|
// each page owning its own single-entity sidebar/sheet.
|
|
import { derived, get, type Readable } from 'svelte/store'
|
|
import { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte'
|
|
import { persist } from '@surdeddd/wmkit/persist'
|
|
import { appById, appWindowId } from '$lib/apps'
|
|
import { toggleDocked } from '$lib/stores/docked'
|
|
import { sessions } from '$lib/stores/chat'
|
|
import { heading } from '$lib/tasks'
|
|
|
|
// Window ids for a task/session's chat window are namespaced `session:<id>`
|
|
// — see openTaskWindow below. Shared here (rather than each file redeclaring
|
|
// its own copy) since both WindowLayer.svelte and focusedSessionId below
|
|
// need to parse it.
|
|
export const SESSION_PREFIX = 'session:'
|
|
|
|
export const wm = createManager({ defaultSize: { width: 480, height: 560 } })
|
|
|
|
// New windows (and manual resizing) must never exceed the visible desktop
|
|
// area — without this, a content-heavy entity window (many Details/
|
|
// Relations/Tasks sections) grows taller than the viewport with no way to
|
|
// reach its own titlebar controls. Clamps requested width/height down to
|
|
// the current viewport and caps maxWidth/maxHeight the same way, so
|
|
// dragging a resize handle can't push it past the edge either.
|
|
function clampToDesktop<
|
|
T extends { width?: number; height?: number; maxWidth?: number; maxHeight?: number }
|
|
>(init: T): T {
|
|
const { viewport } = wm.getState()
|
|
if (viewport.width <= 0 || viewport.height <= 0) return init
|
|
return {
|
|
...init,
|
|
width: init.width !== undefined ? Math.min(init.width, viewport.width) : undefined,
|
|
height: init.height !== undefined ? Math.min(init.height, viewport.height) : undefined,
|
|
maxWidth: Math.min(init.maxWidth ?? viewport.width, viewport.width),
|
|
maxHeight: Math.min(init.maxHeight ?? viewport.height, viewport.height)
|
|
}
|
|
}
|
|
export const dk = createDesktop(wm, {
|
|
// topEdge:'maximize' + preview gives the classic drag-to-top-maximizes
|
|
// affordance; magnetism/keyboard are wmkit defaults worth turning on now
|
|
// that windows are the whole app's primary surface, not a secondary layer.
|
|
snap: { topEdge: 'maximize', preview: true },
|
|
keyboard: true,
|
|
magnetism: true,
|
|
// Animates a minimized window toward its taskbar button instead of just
|
|
// vanishing — Taskbar.svelte tags each button with this same attribute.
|
|
minimizeTarget: (win) => document.querySelector(`[data-taskbar-btn="${CSS.escape(win.id)}"]`)
|
|
})
|
|
export const wmState = wmStore(wm)
|
|
|
|
// The session id backing whichever task/chat window currently has focus, or
|
|
// null when no task window is focused (Tasks app, an entity window, or
|
|
// nothing at all). The desktop mascot's stimuli (stimuli.ts) key off this so
|
|
// its reactions track the task the operator is actually looking at, rather
|
|
// than firing for every session fleet-wide.
|
|
export const focusedSessionId: Readable<string | null> = derived(wmState, ($s) =>
|
|
$s.focusedId?.startsWith(SESSION_PREFIX) ? $s.focusedId.slice(SESSION_PREFIX.length) : null
|
|
)
|
|
|
|
// Layout survives reloads: every window id is self-describing (app:<id>,
|
|
// session:<id>, or a bare entity slug — see EntityDesktop/WindowLayer's
|
|
// content branch), so a hydrated window needs no extra bookkeeping to know
|
|
// what to render once the desktop remounts.
|
|
export const wmPersist = persist(wm, { key: 'oikos-windows', debounce: 300, autoRestore: true })
|
|
|
|
// A task window is titled from the operator's (truncated) prompt at
|
|
// creation time — openTaskWindow below and chat.ts's startTask both only
|
|
// know the raw text, not the goal/heading the backend eventually derives
|
|
// for the session. Whenever the sessions list refreshes (loadSessions(),
|
|
// called all over — on task events, after a turn completes, ...) resync
|
|
// any open task window's title to the session's real heading, so the
|
|
// taskbar/titlebar stop showing the placeholder forever.
|
|
sessions.subscribe((list) => {
|
|
for (const s of list) {
|
|
const id = `session:${s.id}`
|
|
const win = wm.get(id)
|
|
if (!win) continue
|
|
const title = heading(s)
|
|
if (win.title !== title) wm.update(id, { title })
|
|
}
|
|
})
|
|
|
|
// Classic show-desktop toggle: minimize everything, or if everything's
|
|
// already minimized (a prior show-desktop, or the operator minimized them
|
|
// all by hand), bring them all back rather than being a one-way action.
|
|
// Shared by the taskbar button and the desktop's right-click menu.
|
|
export function toggleShowDesktop(): void {
|
|
const anyVisible = wm.getState().order.some((id) => wm.get(id)?.stage !== 'minimized')
|
|
if (anyVisible) wm.minimizeAll()
|
|
else wm.restoreAll()
|
|
}
|
|
|
|
// Opens (or focuses/restores) a registry app's window. Apps are
|
|
// single-instance — double-clicking an already-open app's icon should never
|
|
// stack a second window, same dedupe pattern as openEntityWindow below.
|
|
// Docked apps (e.g. the mascot) have no wmkit window at all — clicking their
|
|
// icon toggles visibility on the Docked Layer instead, so this branches on
|
|
// kind before touching the window manager. All callers (the desktop icon,
|
|
// the taskbar settings button, legacy hash resolution) go through here, so
|
|
// none of them need a kind-specific branch.
|
|
export function openAppWindow(appId: string): void {
|
|
const app = get(appById).get(appId)
|
|
if (!app) return
|
|
if (app.docked) {
|
|
toggleDocked(appId)
|
|
return
|
|
}
|
|
const id = appWindowId(appId)
|
|
if (wm.get(id)) {
|
|
wm.restore(id)
|
|
wm.focus(id)
|
|
return
|
|
}
|
|
wm.open(
|
|
clampToDesktop({
|
|
id,
|
|
title: app.title,
|
|
width: app.width,
|
|
height: app.height,
|
|
minWidth: app.minWidth,
|
|
minHeight: app.minHeight
|
|
})
|
|
)
|
|
}
|
|
|
|
// Opens a window for the entity, or focuses (and restores, if minimized) the
|
|
// existing one — wm.open() throws if a window with this id already exists,
|
|
// and slugs make natural, stable window ids (also dedupes "same entity
|
|
// opened twice" into one window instead of stacking duplicates).
|
|
export function openEntityWindow(slug: string | null): void {
|
|
if (!slug) return
|
|
if (wm.get(slug)) {
|
|
wm.restore(slug)
|
|
wm.focus(slug)
|
|
return
|
|
}
|
|
wm.open(clampToDesktop({ id: slug, title: slug }))
|
|
}
|
|
|
|
// Singleton "compose a new task" window — the Tasks app's New Task button
|
|
// opens this rather than a dialog, since everything else in the desktop is
|
|
// already a window. It renders as an empty chat (NewTaskChat, in
|
|
// WindowLayer.svelte) sized like a real task window rather than a separate
|
|
// compose screen, and closes itself once the task's session window takes
|
|
// over.
|
|
export const NEW_TASK_WINDOW_ID = 'new-task'
|
|
|
|
// Seed text for the next new-task window. Module state rather than a window
|
|
// property because wmkit windows carry only geometry — WindowLayer reads this
|
|
// when it mounts NewTaskChat. Cleared on read so a later manually-opened task
|
|
// does not inherit a stale prompt.
|
|
let pendingTaskDraft = ''
|
|
|
|
export function takePendingTaskDraft(): string {
|
|
const draft = pendingTaskDraft
|
|
pendingTaskDraft = ''
|
|
return draft
|
|
}
|
|
|
|
export function openNewTaskWindow(draft = ''): void {
|
|
pendingTaskDraft = draft
|
|
if (wm.get(NEW_TASK_WINDOW_ID)) {
|
|
wm.restore(NEW_TASK_WINDOW_ID)
|
|
wm.focus(NEW_TASK_WINDOW_ID)
|
|
return
|
|
}
|
|
wm.open(
|
|
clampToDesktop({
|
|
id: NEW_TASK_WINDOW_ID,
|
|
title: 'New task',
|
|
width: 900,
|
|
height: 640,
|
|
minWidth: 600,
|
|
minHeight: 400
|
|
})
|
|
)
|
|
}
|
|
|
|
// Same dedupe/restore/focus pattern as openEntityWindow, for a task/session's
|
|
// chat window. Id is namespaced `session:<id>` — distinct from entity window
|
|
// ids (always a bare `type:identifier` slug, and task ENTITIES already use
|
|
// `task:<uuid>` as their own slug) so a task's chat window and its entity
|
|
// detail window never collide over the same wmkit id. See
|
|
// WindowLayer.svelte for the id -> content-component branch.
|
|
export function openTaskWindow(sessionId: string | null, title: string): void {
|
|
if (!sessionId) return
|
|
const id = `session:${sessionId}`
|
|
if (wm.get(id)) {
|
|
wm.restore(id)
|
|
wm.focus(id)
|
|
return
|
|
}
|
|
wm.open(clampToDesktop({ id, title, width: 900, height: 640, minWidth: 600, minHeight: 400 }))
|
|
}
|