7 Commits

Author SHA1 Message Date
9f4d645d06 docs: plan a pixel-art desktop mascot ("Cluck")
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Design-only (no code yet): an MBSE subsystem model for a chicken mascot
that roams the desktop shell, is draggable, opens a Sims-style nested
radial menu, and has a tamagotchi lifecycle (egg -> chick -> adult) that
reacts to real app activity (chat streaming, knowledge-graph writes,
signals). Everything (animations, autonomous behaviors, menu actions,
environment reactions) is scoped as a data-driven registry for easy
extension.

- docs/mascot/README.md: subsystem Model conforming to docs/mbse's
  Holt-based Framework — mission/boundary, requirements, structural view
  (module registry map), behavioral view (behavior FSM + lifecycle state
  machines + a stimulus sequence diagram), interfaces view (which web
  stores it observes, read-only), extension guide, verification view.
- plans/2026-07-20-desktop-mascot.md: the concrete file-by-file
  implementation plan for web/src/lib/mascot/ derived from the model,
  with an ordered build sequence and a manual browser verification
  checklist.
- Indexed both in docs/index.md and plans/index.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 11:23:15 +02:00
aee458ce83 feat(web): add windowed Settings app, separate from initial Config screen
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
The taskbar's gear icon reopened the full-page "Connect to Oikos" screen
even once already connected. Split that: Config.svelte stays as the
first-run/unconfigured screen; a new Settings app (windowed, like Tasks or
Operations) now handles in-session changes, with a section list (Connection,
Appearance) built to grow — future settings are one more entry, not a new
screen.

- pages/Settings.svelte: Connection (server URL/token/Authentik, reusing
  config.ts + oidc.ts) and Appearance (Terracotta/Carbon picker) sections.
- apps.ts: registered as a normal desktop app.
- Taskbar's gear button now opens the Settings window; removed the
  onOpenConnection prop threaded through App -> Desktop -> Taskbar, since
  Settings' "Forget saved connection" (clear config + reload) replaces it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 11:00:57 +02:00
58a11ca872 feat(web): resizable panels via svelte-splitpanes + Claude-style composer
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Replace hand-rolled pointer-resize logic (TaskContextPanel's 3-way vertical
split, SessionChatWindow's rail, ChatThread's message/input split) with
svelte-splitpanes, themed onto the app's existing border/primary tokens.

- TaskContextPanel: Scope/Plan/Event-log sections collapse to a fixed header
  height and restore their last size on reopen.
- ChatThread: input area is now a separate resizable pane, clamped to a
  measured one-line minimum and a 45% max, instead of a fixed max-h textarea.
- Send button restyled to sit inside the input's corner (Claude-style),
  swapping the up-arrow for a corner-down-left return icon.
- Adds a $app/environment shim + optimizeDeps exclude, since
  svelte-splitpanes assumes SvelteKit and this is a plain Vite app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 10:32:54 +02:00
aed068de12 feat(web): redesign UI as an OS-style desktop shell
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Replace the sidebar + hash-routed page shell with a desktop metaphor:
draggable app icons, apps opening as floating wmkit windows, a centered
"What should Nomos do?" task launcher, and a bottom taskbar showing all
open windows plus a system tray.

- New app registry ($lib/apps.ts) — adding an app is one entry, nothing
  else to touch.
- New desktop shell components (Desktop, WindowLayer, DesktopIcon,
  Taskbar, TaskLauncher) under $lib/components/desktop-shell/.
- Icon positions are a persisted, collision-avoiding grid ($lib/stores/icons.ts).
- Window layout persists across reloads (wmkit persist), with
  drag-to-maximize, F6 window cycling, and now a right-click desktop menu
  (cascade/tile/show desktop/reset icons) plus Cmd/Ctrl+Z undo/redo for
  window moves, resizes, and closes.
- Taskbar buttons get a hover-close and self-correct their title once a
  new task's real goal is known.
- New task windows (desktop launcher and the Tasks app's "New task"
  button) open as a window, not a dialog, and hand off to the real
  session window once the backend assigns an id.
- Fixed a real gap along the way: GET /sessions/{id} couldn't tell
  "session deleted" from "session has no messages yet" (both returned
  200 with an empty list) — cmd/nomos/main.go now checks existence and
  404s, so a stale/persisted task window shows "Task not found" instead
  of a misleadingly empty, live-looking chat.
- Test coverage for the new pure logic (icon placement/collision
  avoidance, app registry id helpers) plus a vitest matchMedia polyfill
  needed to import anything touching the theme store.

Deletes the now-superseded sidebar shell, MinimizedWindowsBar, and the
standalone Chat/EntityDetail pages (folded into the window layer).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 21:34:15 +02:00
8657ac5669 feat(web): open tasks/sessions as floating windows with independent live chat
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Clicking a task now opens it as a wmkit floating window (like entity
windows already do) instead of navigating away from wherever you were.
Several task windows can be open and actively streaming at once, each
fully independent — no "which one's on screen" guard needed, since
each window owns its own store bundle:

- chat.ts: chatFor(sessionId)/loadSessionChat/sendSessionMessage give
  each window its own messages/streaming/connectionState, alongside
  the existing singleton path the main Chat page still uses unchanged.
- workspace.ts: same split for plan/questions/touched/health-diffs
  (workspaceFor/startSessionWorkspace), each with its own live-event
  watermark since several windows can watch the same event stream.
- activity.ts: activityLogFor(sessionId) mirrors the global derivation.

SessionGraph.svelte, OperatorQuestion.svelte, and ActivityTimeline.svelte
were converted from store-importing to prop-driven (matching the new
ChatThread.svelte, extracted from Chat.svelte's transcript/input so both
the main page and task windows share one implementation instead of
duplicating markup/styling) so each can render either the global
"current session" or a specific window's session.

Also: minimized-window taskbar chips now cap at a max width with
middle-ellipsis truncation instead of growing unbounded, and the
window header's title/action-button row is fixed to genuinely match
heights (not just share a center point) for more robust alignment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 17:01:34 +02:00
e28e0e9ea3 feat(web): unify Knowledge Base filtering into one type multiselect
Replace the Fleet/Network/Identity/Knowledge category tabs (which
scoped entity fetches server-side) with a single "Types" multiselect
shared by both the table and graph views — both now fetch the whole
entity set (paginated via the new fetchAllEntities) and filter
client-side, defaulting to fleet's types. Table and graph also share
one search/highlight field instead of two separately-labeled ones.

Along the way, fixed a real bug the wider entity set exposed: the
treegrid's parent/child grouping fired one fetchGraph call per
candidate root entity, fine for the old ~50-entity fleet scope but an
ERR_INSUFFICIENT_RESOURCES flood once scoped to the full ~1700-entity
set. Replaced with a single whole-graph fetch, deriving parent/child
pairs from its edges client-side.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 17:01:34 +02:00
6051fb4845 feat(web): curved edges + unique SVG ids for concurrent graph views
Quadratic-bezier edges instead of straight lines, and drop the
auto-refit-on-load that caused a jarring zoom/pan snap once the force
simulation settled. Also namespace each graph's dot-grid pattern id
with a per-instance uuid — multiple SessionGraph instances can now be
mounted at once (one per open task window), and duplicate SVG ids
silently blanked out every graph's background but the first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 17:01:34 +02:00
41 changed files with 3509 additions and 1395 deletions

View File

@@ -18,6 +18,7 @@ import (
"github.com/dtoro/oikos/internal/safego"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func main() {
@@ -448,6 +449,21 @@ 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 {
if err == pgx.ErrNoRows {
http.Error(w, "session not found", 404)
return
}
http.Error(w, err.Error(), 500)
return
}
messages, err := st.getMessages(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), 500)

View File

@@ -8,6 +8,7 @@ procedures, and the system model.
| ---- | -------- |
| [adr/](adr/README.md) | Architecture Decision Records (numbered, append-only) |
| [mbse/](mbse/README.md) | Model-Based Systems Engineering views of the platform |
| [mascot/](mascot/README.md) | MBSE subsystem model for the desktop mascot (planned) |
| [operations/](operations/README.md) | Operator runbooks (deploy, rollback, recovery) |
For agent orientation see [AGENTS.md](../AGENTS.md); for the operating model

335
docs/mascot/README.md Normal file
View File

@@ -0,0 +1,335 @@
# Oikos — Desktop Mascot Subsystem Model
> Companion to [the platform Model](../mbse/README.md) and
> [the Framework](../mbse/framework.md). This document is a **subsystem
> Model** in Holt's sense — it conforms to the same Framework (Ontology +
> Viewpoints, Markdown + Mermaid Notation) rather than restating it, scoped
> to a single not-yet-built subsystem of the `web` component: the desktop
> mascot ("Cluck"), a pixel-art chicken that lives on the desktop shell.
> Where the platform-wide Views in [../mbse/README.md](../mbse/README.md)
> and the component View for `web/src` in
> [../mbse/components.md](../mbse/components.md#5-web-control-room) speak
> at the level of "the SPA," this document goes one layer deeper into one
> 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).
## Views in this model
| # | View | Concern it addresses |
|---|---|---|
| [1](#1-mission--system-context) | Mission & System Context | Why a mascot, and what is it never allowed to do? |
| [2](#2-requirements) | Requirements | What must it do, traced from the original request? |
| [3](#3-structural-view) | Structural View | What modules make it up, and which are the extension points? |
| [4](#4-behavioral-view) | Behavioral View | How does it move, live, and react, moment to moment? |
| [5](#5-interfaces-view) | Interfaces View | What does it read from the rest of the system, and how does it persist itself? |
| [6](#6-extension-guide) | Extension Guide | How does a future engineer add an animation, behavior, menu action, or reaction? |
| [7](#7-verification-view) | Verification View | How will we know it works, once built? |
## 1. Mission & System Context
**Stakeholders:** the operator (delight, ambient awareness of system
state without opening a window); future engineers extending the mascot's
behaviors/reactions/menu.
**Mission:** give the desktop shell a persistent, living presence that
makes background system activity legible at a glance — a chat streaming,
a knowledge-graph write, a critical signal — without requiring a window to
be open, while doubling as a lightweight tamagotchi for its own sake
(delight is a legitimate requirement here, not a side effect).
**Boundary — what the mascot is, and is not:**
- It is a **purely client-side, read-only observer**. It subscribes to
existing `web` stores (chat, activity, events, dashboard summary) the
same way any other UI component does.
- It **never calls a mutating API endpoint** and is not a new actuation
path — it has no relationship to the `run` gate, `Execution`, or
`Approval` entities described in [the platform Ontology](../mbse/ontology.md).
Its only "mutation" is its own tamagotchi state, stored client-side.
- It is scoped entirely inside the `web` component
([../mbse/components.md §5](../mbse/components.md#5-web-control-room));
it introduces no new backend surface, no new MCP tool, no new REST route.
```mermaid
flowchart TB
subgraph SURFACE["Desktop shell surface (Desktop.svelte)"]
ICONS["Icon layer\nz-0"]
LAUNCH["Task launcher\nz-10"]
WIN["WindowLayer\nz-40"]
MASCOT["MascotLayer\nz-45\n(this subsystem)"]
MENU["Desktop context menu\nz-50"]
end
MASCOT -->|subscribes, read-only| EVENTS["stores/events.ts\nliveEvents (SSE)"]
MASCOT -->|subscribes, read-only| CHAT["stores/chat.ts\nstreaming"]
MASCOT -->|subscribes, read-only| ACTIVITY["stores/activity.ts\nactivityLog"]
MASCOT -->|subscribes, read-only| CONTEXT["stores/context.ts\nsummary"]
MASCOT -->|reads/writes| LS["localStorage\noikos-mascot"]
style MASCOT fill:#fff3e0,stroke:#e65100
```
## 2. Requirements
Traced from the original feature request. All **Planned**.
| 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 |
## 3. Structural View
**Stakeholders:** an engineer implementing or extending the mascot.
**Why this View earns its place:** MASC-9 (extensibility) is only real if
the module boundaries actually separate data (registries) from engine
code; this View is the check that they do.
```mermaid
classDiagram
class types_ts {
<<module>>
PixelGrid
AnimName
MascotStage
BehaviorId
Stimulus
RadialAction
}
class palette_ts {
<<module, registry>>
PALETTE: char to CSS color
}
class sprites_ts {
<<module, registry>>
SPRITES: Stage to AnimName to AnimDef
resolveAnim(stage, name)
}
class render_ts {
<<module, stateless>>
drawFrame(ctx, grid, palette, flip)
}
class state_svelte_ts {
<<module, runes>>
MascotModel state
grantXp() feed() pet() setName()
tickLifecycle() advanceStageIfReady()
persist (debounced, oikos-mascot)
}
class behavior_ts {
<<module, registry>>
BEHAVIORS: BehaviorId to BehaviorDef
stepMascot(rt, model, now, dt)
}
class stimuli_ts {
<<module, registry>>
REACTIONS: id to ReactionDef
attachStimuli(emit)
}
class actions_ts {
<<module, registry>>
MASCOT_ACTIONS: RadialAction tree
registerMascotAction()
}
class Mascot_svelte {
<<component>>
canvas render loop 30fps
pointer drag/click/contextmenu
}
class MascotLayer_svelte {
<<component>>
z-45 absolute overlay
hosts Mascot + RadialMenu + bubble
}
class RadialMenu_svelte {
<<component>>
z-60 fixed, nested rings
}
class NameDialog_svelte {
<<component>>
}
sprites_ts --> palette_ts : indexes
sprites_ts --> types_ts : uses
Mascot_svelte --> render_ts : draws frames
Mascot_svelte --> sprites_ts : resolves anim
Mascot_svelte --> behavior_ts : steps FSM
Mascot_svelte --> state_svelte_ts : reads/mutates model
MascotLayer_svelte --> Mascot_svelte : hosts
MascotLayer_svelte --> RadialMenu_svelte : hosts, on contextmenu
MascotLayer_svelte --> stimuli_ts : attaches on mount
MascotLayer_svelte --> NameDialog_svelte : hosts, on hatch/rename
RadialMenu_svelte --> actions_ts : renders tree
stimuli_ts --> behavior_ts : forceBehavior(react)
```
**The four extension registries** (MASC-9's concrete answer — see also
[§6 Extension Guide](#6-extension-guide)): `SPRITES` (animations),
`BEHAVIORS` (autonomous states), `MASCOT_ACTIONS` (radial menu tree),
`REACTIONS` (environment stimuli). Each is plain data; the engine
(`behavior.ts`'s `stepMascot`, `Mascot.svelte`'s loop, `RadialMenu.svelte`'s
renderer) is generic over whatever the registry currently contains.
**Mount point:** two lines in
[`Desktop.svelte`](../../web/src/lib/components/desktop-shell/Desktop.svelte) —
`<MascotLayer />` rendered inside the surface `<div>` (the `relative
min-h-0 flex-1 overflow-hidden` element), after `<WindowLayer />`, so its
`absolute inset-0` shares the surface's coordinate space and its ground
line is exactly the surface's bottom edge (= the taskbar's top edge).
## 4. Behavioral View
**Stakeholders:** an engineer reasoning about "what does the mascot do
right now, and why." **Why this View earns its place:** a mascot with an
implicit, ad-hoc state machine is unmaintainable the moment a second
behavior or reaction is added; this View is the state machine made
explicit before any of it is coded.
### 4.1 Behavior FSM (moment-to-moment autonomy)
```mermaid
stateDiagram-v2
[*] --> egg
egg --> chick : hatchProgress reaches 1\n(advanceStageIfReady)
state chick_and_adult_behaviors {
[*] --> idle
idle --> wander : weighted random pick\non behaviorUntil expiry
wander --> idle
idle --> peck : weighted random pick
peck --> idle
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
land --> idle
[*] --> react : stimulus dispatched\n(priority/cooldown gated)
react --> idle : durationMs elapsed,\nreturns to prior-or-idle
}
chick --> adult : xp reaches ADULT_XP\n(advanceStageIfReady)
```
`dragged` always wins over any autonomous behavior; `sleep` is broken only
by a reaction whose `ReactionDef.interruptsSleep` is true (§4.3) or by a
drag. Weighted-random idle selection (`weight` field in `BehaviorDef`)
picks the next autonomous behavior only when the current one's `next()`
returns null past `behaviorUntil` — see
[plans/2026-07-20-desktop-mascot.md](../../plans/2026-07-20-desktop-mascot.md)
for the concrete weights.
### 4.2 Tamagotchi lifecycle (long-lived state)
```mermaid
stateDiagram-v2
[*] --> egg : first load,\ndefaultModel()
egg --> chick : active time >= HATCH_MS (3min)\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).
### 4.3 Example sequence — an environment stimulus becomes a visible reaction
```mermaid
sequenceDiagram
participant SSE as stores/events.ts (SSE)
participant Stim as stimuli.ts attachStimuli
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})
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
Note over FSM: after durationMs,\nnext() returns to idle
```
## 5. Interfaces View
**Stakeholders:** an engineer wiring a new store into the mascot's
awareness, or auditing what it depends on.
| Interface | Direction | Shape | Notes |
|---|---|---|---|
| [`stores/events.ts`](../../web/src/lib/stores/events.ts) `liveEvents` | consumed | `Writable<OikosEvent[]>`, newest-first, ref-counted via `subscribeEvents()` | `OikosEvent.type` families: `approval.*`, `signal.*`, `execution.*`, `health.changed`; `severity: 'info'\|'warning'\|'critical'` |
| [`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) |
| [`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
with §1's boundary statement (MASC-11).
## 6. Extension Guide
**Stakeholders:** a future engineer adding one new animation, behavior,
menu action, or reaction — this is the Viewpoint 4's "why" made concrete
as a recipe rather than prose (mirrors [../mbse/framework.md §7](../mbse/framework.md)'s
Process Set treatment).
| To add a... | Touch only | Nothing else changes because |
|---|---|---|
| **Animation** | Add the name to the `AnimName` union in `types.ts`; add frames to `SPRITES[stage]` in `sprites.ts` | `resolveAnim()` and the renderer are generic over the registry |
| **Behavior** | Add the id to `BehaviorId`; add one `BehaviorDef` entry to `BEHAVIORS` in `behavior.ts` | `stepMascot()` and the weighted-random idle selector consume `BEHAVIORS` generically |
| **Radial menu action** | Add a `RadialAction` node to `MASCOT_ACTIONS` in `actions.ts` (or call `registerMascotAction()`), optionally nested under `children` | `RadialMenu.svelte` renders whatever tree it's given, including nesting depth |
| **Environment reaction** | Add a `ReactionDef` to `REACTIONS` in `stimuli.ts`; wire one `store subscription -> predicate -> emit(reaction)` block inside `attachStimuli()` | priority/cooldown/interrupt dispatch logic in `attachStimuli()` is generic over `REACTIONS` |
## 7. Verification View
**Stakeholders:** whoever implements this subsystem and needs to know
when it's actually done, not just compiled.
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).
- 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.
- 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
triggering the desktop's own right-click menu; a nested submenu (Feed)
opens correctly; Escape pops one level then closes; an outside click
closes it; the menu stays fully visible when the chicken is near a
screen edge or corner.
- With one or more windows open (including a maximized one), the chicken
visibly walks above them without breaking window drag/resize/close.
- Starting a chat and observing it stream triggers the `thinking` reaction
for the duration; a simulated knowledge-graph write triggers `eureka`
once per cooldown window; a simulated critical signal triggers `alarmed`
even while the chicken is asleep.
- Resizing the browser viewport re-grounds and re-clamps the chicken.
- Both the Terracotta and Carbon themes keep the pixel-art palette legible.
- `npm run build` passes with no new errors.

View File

@@ -0,0 +1,376 @@
# 2026-07-20 — Desktop mascot ("Cluck")
**Status:** Planned
## Why
The web control room is an OS-style desktop shell (icons, floating
windows, taskbar) but has no ambient, always-visible signal of what the
system is doing — you have to open a window to see a chat streaming, a
knowledge-graph write, or a critical signal land. The user asked for a
pixel-art chicken mascot that roams the desktop, is draggable and
interactable (Sims-style radial right-click menu with nested actions), and
is itself a tamagotchi (egg → chick → adult, nameable, persistent) that
visibly reacts to real app activity. This plan is scaffolding: every piece
(sprites, autonomous behaviors, menu actions, environment reactions) is a
data-driven registry so each can be extended independently later without
touching the engine code.
The MBSE subsystem model for this feature (Mission, Requirements,
Structural/Behavioral/Interfaces views, Verification) lives at
[docs/mascot/README.md](../docs/mascot/README.md) — read it first for the
full rationale and diagrams; this document is the concrete file-by-file
implementation plan derived from it.
**Design decisions already made with the user:**
- Renders **above windows** (desktop-pet style) — mascot layer `z-45`,
radial menu `z-[60]` (must beat the desktop's own right-click menu,
which is `z-50`).
- Art is **code-drawn pixel art** — string pixel-grids + a palette map in
TypeScript, rendered to a small canvas, no binary sprite assets.
- Movement is **gravity + ground** — walks along the desktop surface's
bottom edge (= the taskbar's top edge), flutter-falls when dropped
mid-air.
## Verified codebase facts this plan builds on
- `web/src/lib/components/desktop-shell/Desktop.svelte` — the surface div
(`relative min-h-0 flex-1 overflow-hidden`) hosts layered children: icon
layer `z-0`, `TaskLauncher` wrapper `z-10`, `WindowLayer` `z-40` — every
wrapper is `pointer-events-none` with interactive children re-enabling
`pointer-events-auto`. The desktop's own right-click menu is `fixed
z-50`, dismissed via `<svelte:window onclick={closeMenu}>` + Escape.
Bare-surface clicks are gated with `e.currentTarget === e.target`.
- Drag pattern to copy: `desktop-shell/DesktopIcon.svelte`
`pointerdown` + `el.setPointerCapture(e.pointerId)`, a 5px movement
threshold distinguishes a click from a drag, move/up listeners attached
to the element itself (not `window`), position blended via `$derived`
between rest and drag-in-progress values.
- Game loop convention: `GraphBackground.svelte` drives its canvas with
`setTimeout(() => draw(performance.now()), 33)` (~30fps), **not**
`requestAnimationFrame` — the code comment there explains some embedding
contexts report `document.hidden=true` and suspend rAF, which would
freeze the animation; `setTimeout` keeps ticking. Follow this for the
mascot loop, and clamp `dt` to 100ms so a throttled/backgrounded tab
doesn't produce a physics-breaking huge step on resume.
- Persistence convention: hyphenated `oikos-*` localStorage keys
(`oikos-desktop-icons`, `oikos-theme`, `oikos-windows`). Window layout
uses wmkit's `persist(wm, { key: 'oikos-windows', debounce: 300,
autoRestore: true })` — mirror the 300ms debounce for `oikos-mascot`;
never write on every animation frame, only on discrete state
transitions (behavior change, drag end, stage change, rename).
- Runes idiom for cross-component client state: a `.svelte.ts` module with
module-level `$state` plus exported getter/mutator functions —
`web/src/lib/stores/theme.svelte.ts` is the canonical example
(`let current: Theme = $state(initialTheme)`, `getTheme()`,
`setTheme()`, `toggleTheme()`).
- Awareness sources, all plain Svelte stores already in the codebase:
- `web/src/lib/stores/events.ts``liveEvents: Writable<OikosEvent[]>`
(newest-first, capped at 200), fed by a ref-counted SSE subscription
`subscribeEvents()`. `OikosEvent.type` families: `approval.*`,
`signal.*`, `execution.*`, `health.changed`; `severity: 'info' |
'warning' | 'critical'`.
- `web/src/lib/stores/chat.ts``streaming: Writable<boolean>`.
- `web/src/lib/stores/activity.ts``activityLog` is a **derived**
store recomputed wholesale from `messages`/`planSteps`/`currentTask`
on every emission, **not an append-only log** — detecting a "new"
entry (e.g. `type === 'knowledge'`) requires diffing entry `id`s
against the previous emission, not just reacting to the store firing.
- `web/src/lib/stores/context.ts` — `summary: Writable<DashboardSummary
| null>`, `openSignalCount(summary)`.
- No `@keyframes`, no `requestAnimationFrame`, no sprite/pixel-art code
exists anywhere in the repo today — this is greenfield within the
established canvas-loop convention above.
## File layout
All new, under `web/src/lib/mascot/`:
```
types.ts PixelGrid, AnimName, MascotStage, BehaviorId, Stimulus, RadialAction
palette.ts Record<char, cssColor>; '.' = transparent
sprites.ts SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>> + resolveAnim() fallback
render.ts drawFrame(ctx, grid, palette, flip) — stateless canvas painter
state.svelte.ts Tamagotchi model: module $state + mutators, debounced persist, versioned schema
behavior.ts FSM: BEHAVIORS registry + stepMascot(rt, model, now, dt)
stimuli.ts Stimulus bus: REACTIONS registry + attachStimuli(emit), ref-counted
actions.ts MASCOT_ACTIONS radial tree + registerMascotAction()
Mascot.svelte canvas sprite, 30fps loop, pointer drag/click/contextmenu
MascotLayer.svelte pointer-events-none absolute inset-0 z-45 overlay; hosts Mascot + RadialMenu + bubble
RadialMenu.svelte round nested menu, fixed z-[60]
NameDialog.svelte naming prompt (hatch + rename)
```
**Integration — 2 lines in `Desktop.svelte`:** import `MascotLayer` and
render `<MascotLayer />` inside the surface `<div>`, after `<WindowLayer
/>`, so its `absolute inset-0` shares the surface's coordinate space and
its ground line lands exactly at the surface's bottom edge (the taskbar's
top edge).
## Sprite system (`types.ts`, `palette.ts`, `sprites.ts`, `render.ts`)
Frames are human-editable string pixel-grids indexing a palette, e.g.:
```ts
export type PixelGrid = string[] // rows of same-length strings, one char per pixel
export interface AnimDef { frames: PixelGrid[]; fps: number; loop: boolean }
export type AnimName =
| 'egg-idle' | 'egg-wiggle' | 'egg-crack' | 'hatch'
| 'idle' | 'blink' | 'walk' | 'peck' | 'flap' | 'sleep'
| 'dragged' | 'fall-flutter' | 'land'
| 'react-eureka' | 'react-alarm' | 'react-think' | 'react-happy'
```
- Grids: egg 12×12, chick 14×14, adult 16×16, all bottom-anchored inside a
fixed 20×20 logical canvas so feet land on the ground line consistently
across stages.
- `SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>>` is
the registry; `resolveAnim(stage, name)` falls back to that stage's
`idle` and finally a 1-frame placeholder, so a missing animation never
crashes the renderer.
- Canvas is sized to the logical grid; screen scale is pure CSS (`width:
20*SCALE px; image-rendering: pixelated`), `ctx.imageSmoothingEnabled =
false` set once. Horizontal facing flip via `ctx.translate(w,0);
ctx.scale(-1,1)` — no mirrored frame data needed.
- Initial animation set (24 frames each): egg-idle/egg-wiggle/egg-crack/
hatch; idle/blink/walk/peck/flap/sleep; dragged/fall-flutter/land;
react-think/react-eureka/react-alarm/react-happy.
- Frame index = `floor((now - animStart) / 1000 * fps)`, wrapped if
`loop`.
## Behavior engine (`behavior.ts`)
```ts
export interface MascotRuntime {
x: number; y: number // sprite bottom-center, surface coords
vx: number; vy: number
facing: 1 | -1
behavior: BehaviorId // 'egg' | 'idle' | 'wander' | 'peck' | 'sleep' | 'dragged' | 'falling' | 'react'
behaviorUntil: number
anim: AnimName
animStart: number
reactAnim: AnimName | null
bounds: { w: number; h: number }
}
export interface BehaviorDef {
id: BehaviorId
anim: (rt: MascotRuntime, model: MascotModel) => AnimName
enter?: (rt: MascotRuntime) => void
tick: (rt: MascotRuntime, dt: number, now: number) => void
next: (rt: MascotRuntime, now: number) => BehaviorId | null
weight?: number // idle-selectable when > 0; undefined/0 = not auto-picked
minMs: number; maxMs: number
}
export const BEHAVIORS: Record<BehaviorId, BehaviorDef>
export function stepMascot(rt: MascotRuntime, model: MascotModel, now: number, dt: number): void
export function forceBehavior(rt: MascotRuntime, id: BehaviorId, opts?: { anim?: AnimName; durationMs?: number }): void
```
- **Ground/gravity**: `GROUND_Y = bounds.h`. When above ground and not
dragged, behavior is `falling`: `vy += GRAVITY * dt`, capped at a slow
flutter terminal velocity, anim `fall-flutter` with occasional `flap`;
on reaching ground, snap `y`, brief `land`, then `idle`.
- **Wander**: constant `vx = facing * ~40px/s`, flip `facing` at the
surface margins.
- **Idle selection**: when `now > behaviorUntil` and the current
behavior's `next()` returns null, roll a weighted random pick over
`BEHAVIORS` entries that declare `weight` — starting weights: idle 3,
wander 4, peck 2, sleep 1.
- **Non-self-selecting behaviors** (`dragged`, `falling`, `react`) have no
`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).
- Loop lives in `Mascot.svelte`: `setTimeout(() => tick(performance.now()),
33)` inside an `$effect`, cleared on teardown, `dt` clamped to 100ms.
## Tamagotchi model (`state.svelte.ts`)
```ts
export type MascotStage = 'egg' | 'chick' | 'adult'
export interface MascotModel {
version: 1
stage: MascotStage
name: string | null
hatchProgress: number // 0..1, 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
}
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
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
```
- `load()` parses `localStorage['oikos-mascot']`, checks `version`, falls
back to `defaultModel()` on mismatch/corruption. `migrate(raw):
MascotModel` is a stub switch on `version` for future schema changes —
v1 has no migrations to perform, the stub just documents where they go.
- Every mutator calls a shared `schedulePersist()` — a 300ms trailing
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.
- 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.
## Radial menu (`actions.ts`, `RadialMenu.svelte`)
```ts
export interface RadialAction {
id: string
label: string
icon?: Component // lucide, same convention as the desktop menu
visible?: (model: MascotModel) => boolean // e.g. Rename only once hatched
children?: RadialAction[]
action?: (ctx: MascotActionCtx) => void // leaf only
}
export const MASCOT_ACTIONS: RadialAction[]
export function registerMascotAction(a: RadialAction, parentId?: string): void
```
v1 tree: **Interact** [Pet, Feed → [Seeds, Worm]], **Care** [Sleep, Wake],
**Identity** [Rename], **Debug** [Force hatch/stage, Reset].
- Rendered by `MascotLayer.svelte` as `fixed`, positioned at the
chicken's screen center, **`z-[60]`** (must beat the desktop context
menu's `z-50`, comfortably above `WindowLayer`'s `z-40`).
- Layout: items on a circle (radius ≈ 70px) via polar `transform`s around
the menu's anchor point. Open animation: buttons start scale-0 at
center and transition to their polar position with `transform 120ms
cubic-bezier(.2,1.4,.4,1)`, staggered ~20ms per item — pure CSS, no
keyframes, reads as snappy/springy per the "snappy" requirement.
- **Nesting**: selecting a node with `children` swaps the ring's contents
to those children plus a center "back" button; track the breadcrumb as
a local `$state<RadialAction[][]>` stack.
- Dismissal mirrors the desktop menu's existing pattern:
`<svelte:window onclick={close}>`, Escape pops one level then closes on
the next press; the menu's own clicks `stopPropagation()`. Clamp the
ring's screen position so it never renders off-viewport (relevant near
screen edges/corners).
- Opened from `Mascot.svelte`'s `oncontextmenu`:
`e.preventDefault(); e.stopPropagation();` then tell `MascotLayer` to
open at the sprite's center (the surface's own `onSurfaceContextMenu`
already gates on `currentTarget === target`, so this is defensive, not
strictly required — but keep it for clarity).
## Stimulus / reaction system (`stimuli.ts`)
```ts
export interface ReactionDef {
id: string
anim: AnimName
priority: number
cooldownMs: number
durationMs: number
interruptsSleep?: boolean
effect?: () => void // e.g. grantXp(5) on eureka
}
export const REACTIONS: Record<string, ReactionDef>
export function attachStimuli(emit: (r: ReactionDef) => void): () => void // ref-counted, owns subscribeEvents()
```
Initial wiring:
| Source | Trigger | Reaction |
|---|---|---|
| `chat.ts` `streaming` | `false → true` edge, held while `true` | `thinking` (`react-think`, priority 1) |
| `activity.ts` `activityLog` | new entry with `type === 'knowledge'`, detected by diffing entry ids against the last-seen set (see note above — the store is recomputed wholesale) | `eureka` (`react-eureka`, priority 2, cooldown 10s, `effect: grantXp(5)`) |
| `events.ts` `liveEvents` | new head event (`id > lastSeen`) with `severity === 'critical'` or `type` starting `signal.` | `alarmed` (`react-alarm`, priority 3, cooldown 15s, `interruptsSleep: true`) |
| `events.ts` `liveEvents` | new head event, `type` starting `execution.`, success-ish | `happy` (`react-happy`, priority 1, cooldown 20s) |
- `attachStimuli` calls `subscribeEvents()` itself and folds its
unsubscribe into the returned teardown, so the mascot keeps the SSE
stream open (ref-counted alongside any page that also subscribes) only
while mounted.
- 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
`priority >= currentReactPriority` (or current behavior isn't
`dragged`), then calls `forceBehavior(rt, 'react', { anim,
durationMs })`. `dragged` always wins over any reaction; `sleep` is
broken only when `interruptsSleep` is true.
## Implementation order (sized for one PR each)
1. `types.ts`, `palette.ts`, `sprites.ts` (egg + chick idle/walk only),
`render.ts` — pure data/functions, no UI yet.
2. `state.svelte.ts` model + debounced persistence — verify by hand via
devtools console before wiring any UI.
3. `behavior.ts` FSM (egg/idle/wander/falling/dragged) + `Mascot.svelte` +
`MascotLayer.svelte`, insert into `Desktop.svelte`. **First visible
milestone** — an egg sits on the ground and can be dragged.
4. Hatch flow: `tickLifecycle` wired into the loop, egg→chick transition +
`NameDialog.svelte`, remaining animations, `peck`/`sleep` behaviors.
5. `actions.ts` + `RadialMenu.svelte` (nested rings, open animation,
dismissal).
6. `stimuli.ts` + the four reaction animations + the wiring table above.
7. Adult stage sprites + XP threshold; polish (speech/name bubble, a
squash frame on `land`).
8. A short "how to add an animation / behavior / action / reaction" doc
comment at the top of `sprites.ts`, `behavior.ts`, `actions.ts`, and
`stimuli.ts` respectively (this plan's registry tables above become
those comments, condensed).
## Verification checklist
Run `npm run dev` in `web/`, then in the browser:
- Egg renders on the ground at the surface bottom, wiggles occasionally,
and is at the same `x` after a reload (`oikos-mascot` in localStorage —
confirm it is *not* being written on every frame while merely idling or
walking, only on discrete transitions).
- Dragging the egg up and releasing triggers a flutter-fall back down with
no tunneling below the taskbar; dragging past the surface's left/right
edges clamps rather than escaping the viewport.
- The debug "force hatch" action transitions egg → chick, opens the name
dialog, and the chosen name persists across a reload.
- The chick wanders and flips its sprite at the surface edges, pecks, and
sleeps on its own; a plain click (below the 5px drag threshold) triggers
a pet/hop reaction and grants a little xp.
- Right-clicking the chicken opens the radial menu centered on it — and
right-clicking bare desktop elsewhere still opens the *original* desktop
menu, unaffected. The nested Feed submenu opens; Escape pops one level
then closes on the next press; clicking outside the menu closes it;
the menu stays fully on-screen when the chicken is near a corner.
- With a maximized window open, the chicken visibly walks above it; window
drag/resize/close still work normally when the chicken merely passes
under the cursor (not when it's directly over a button being clicked —
known, accepted overlap per the "renders above windows" decision).
- Sending a chat message and watching it stream triggers the `thinking`
animation for the duration; simulate (or trigger for real) a
knowledge-graph write and confirm `eureka` fires once and respects its
cooldown on a second write; simulate a critical signal and confirm
`alarmed` fires even while the chicken is asleep.
- Resizing the browser viewport re-grounds the chicken and keeps it
within the new bounds.
- Both the Terracotta and Carbon themes keep the pixel palette legible.
- `npm run build` passes with no new errors or warnings beyond the
pre-existing baseline.
## Risks
- Z-index ordering is easy to get subtly wrong: radial menu must be
`z-[60]` to beat the desktop context menu's `z-50`; the mascot layer
itself is `z-45` (above `WindowLayer`'s `z-40`, below both menus).
- The mascot rendering above windows means it can occlude/steal clicks on
window chrome directly beneath it — accepted per the "above windows"
decision; mitigate by keeping the pointer hitbox tight to the canvas
element only (no oversized invisible padding).
- `activityLog` is a derived store recomputed wholesale on every
emission, not an append-only log — any "new entry" detection must diff
entry ids between emissions, never assume the store only ever grows by
appending.
- `setTimeout`-driven loops can receive large `dt` spikes after tab
throttling/backgrounding resumes — clamp `dt` before feeding it into
physics or lifecycle ticking.

View File

@@ -18,6 +18,7 @@ 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 |
## Done

31
web/package-lock.json generated
View File

@@ -13,6 +13,7 @@
"d3-force": "^3.0.0",
"dompurify": "^3.4.11",
"marked": "^18.0.5",
"svelte-splitpanes": "^8.0.12",
"tailwind-merge": "^3.6.0",
"uplot": "^1.6.32"
},
@@ -877,7 +878,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -888,7 +888,6 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -899,7 +898,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -909,14 +907,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -1320,7 +1316,6 @@
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz",
"integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@@ -1677,7 +1672,6 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
@@ -1691,7 +1685,6 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
@@ -1844,7 +1837,7 @@
"version": "8.64.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz",
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -2066,7 +2059,6 @@
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"devOptional": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -2139,7 +2131,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -2166,7 +2157,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -2548,7 +2538,6 @@
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/dompurify": {
@@ -2858,7 +2847,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/espree": {
@@ -2896,7 +2884,6 @@
"version": "2.2.13",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz",
"integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@@ -3364,7 +3351,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@@ -3788,7 +3774,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/locate-path": {
@@ -3842,7 +3827,6 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -4504,7 +4488,6 @@
"version": "5.56.4",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@@ -4659,6 +4642,15 @@
"svelte": "^5.7.0"
}
},
"node_modules/svelte-splitpanes": {
"version": "8.0.12",
"resolved": "https://registry.npmjs.org/svelte-splitpanes/-/svelte-splitpanes-8.0.12.tgz",
"integrity": "sha512-HJ07HgbtY0Q/35TEuJquGy47dtgCVavV7ay9r1FhWRx3boyUs3RpeiHlwMKliznCKy2ZotNeT+8GG+mIoNjgRA==",
"license": "MIT",
"peerDependencies": {
"svelte": "^5.43.0"
}
},
"node_modules/svelte-toolbelt": {
"version": "0.10.6",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
@@ -6315,7 +6307,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"devOptional": true,
"license": "MIT"
}
}

View File

@@ -45,6 +45,7 @@
"d3-force": "^3.0.0",
"dompurify": "^3.4.11",
"marked": "^18.0.5",
"svelte-splitpanes": "^8.0.12",
"tailwind-merge": "^3.6.0",
"uplot": "^1.6.32"
}

View File

@@ -1,46 +1,42 @@
<script lang="ts">
import Chat from './pages/Chat.svelte'
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 EntityDetail from './pages/EntityDetail.svelte'
import Knowledge from './pages/Knowledge.svelte'
import Learning from './pages/Learning.svelte'
import Config from './pages/Config.svelte'
import EntityDesktop from '$lib/components/EntityDesktop.svelte'
import { newChat } from '$lib/stores/chat'
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
import { currentTask } from '$lib/stores/workspace'
import Desktop from '$lib/components/desktop-shell/Desktop.svelte'
import { subscribeContext } from '$lib/stores/context'
import { openAppWindow, openEntityWindow } from '$lib/stores/windows'
import { isConfigured } from '$lib/config'
import { truncateMiddle } from '$lib/utils'
import { onMount } from 'svelte'
import { processPendingCallback, initOIDC } from '$lib/oidc'
import * as Sidebar from '$lib/components/ui/sidebar'
import * as Sheet from '$lib/components/ui/sheet'
import { Button } from '$lib/components/ui/button'
import { Separator } from '$lib/components/ui/separator'
import { VERSION } from '$lib/version'
import { Toaster } from '$lib/components/ui/sonner'
import PlusIcon from '@lucide/svelte/icons/plus'
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
import DatabaseIcon from '@lucide/svelte/icons/database'
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
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 PaletteIcon from '@lucide/svelte/icons/palette'
import { getTheme, toggleTheme, THEME_LABELS } from '$lib/stores/theme.svelte'
let page = $state('overview')
let routeParam = $state('')
let drawerOpen = $state(false)
let configured = $state(isConfigured())
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
const openSignals = $derived(openSignalCount($summary))
// Old hash routes (#/kb, #/entity/<slug>, ...) from the sidebar-shell era —
// translated into opening the equivalent window once, then cleared, so
// links/bookmarks from before the desktop redesign keep working without
// reintroducing a router.
const LEGACY_APP_ROUTES: Record<string, string> = {
overview: 'tasks',
chat: 'tasks',
kb: 'kb',
entities: 'kb',
graph: 'kb',
ops: 'ops',
signals: 'signals',
knowledge: 'knowledge',
learning: 'learning'
}
function resolveLegacyHash() {
const path = location.hash.slice(2)
if (!path) return
const [head, ...rest] = path.split('/')
if (head === 'entity' && rest.length) {
openEntityWindow(rest.join('/'))
} else if (LEGACY_APP_ROUTES[head]) {
openAppWindow(LEGACY_APP_ROUTES[head])
}
history.replaceState(null, '', location.pathname + location.search)
}
onMount(async () => {
if (await processPendingCallback()) {
@@ -48,21 +44,7 @@
} else if (!configured) {
if (await initOIDC()) configured = true
}
function sync() {
const path = location.hash.slice(2) || 'overview'
const [head, ...rest] = path.split('/')
// Entities + Graph were merged into Knowledge Base — keep old links working.
if (head === 'entities' || head === 'graph') {
location.hash = '#/kb'
return
}
page = head || 'overview'
routeParam = rest.join('/')
}
sync()
window.addEventListener('hashchange', sync)
return () => window.removeEventListener('hashchange', sync)
resolveLegacyHash()
})
// Context (dashboard summary + approvals poll) and the SSE stream both
@@ -71,23 +53,6 @@
if (!configured) return
return subscribeContext()
})
function navigate(p: string) {
location.hash = '#/' + p
}
function cycleTheme() {
toggleTheme()
}
const navItems = [
{ id: 'overview', label: 'Tasks', icon: ListTodoIcon },
{ id: 'kb', label: 'Knowledge Base', icon: DatabaseIcon },
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
{ id: 'learning', label: 'Learning', icon: TrendingUpIcon }
]
</script>
{#if !configured}
@@ -96,160 +61,6 @@
onCancel={isConfigured() ? () => (configured = true) : undefined}
/>
{:else}
<Toaster />
<Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);">
<Sidebar.Root collapsible="icon" variant="inset">
<Sidebar.Header>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton
class="data-[slot=sidebar-menu-button]:!p-1.5"
onclick={() => navigate('overview')}
tooltipContent={`Oikos ${VERSION}`}
>
{#snippet child({ props })}
<button {...props}>
<svg viewBox="0 0 91 100" class="!size-5 shrink-0" fill="var(--primary)" 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>
<div class="group-data-[collapsible=icon]:hidden px-2.5 pb-1">
<span class="text-[11px] text-muted-foreground select-none">{VERSION}</span>
</div>
<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 task"
>
{#snippet child({ props })}
<button {...props}>
<PlusIcon />
<span>New task</span>
</button>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.Menu>
{#each navItems as item}
<Sidebar.MenuItem>
<Sidebar.MenuButton
isActive={page === item.id || (item.id === 'overview' && page === 'chat')}
onclick={() => navigate(item.id)}
tooltipContent={item.label}
>
{#snippet child({ props })}
<button {...props}>
<item.icon />
<span>{item.label}</span>
</button>
{/snippet}
</Sidebar.MenuButton>
{#if item.badge?.()}
<Sidebar.MenuBadge>{item.badge()}</Sidebar.MenuBadge>
{/if}
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
<Button
variant="ghost"
size="sm"
class="justify-start gap-2"
onclick={() => (drawerOpen = true)}
title="Chat over the current page without navigating away"
>
<PanelRightIcon />
<span>Chat drawer</span>
</Button>
<Button
variant="ghost"
size="sm"
class="justify-start gap-2"
onclick={cycleTheme}
title="Cycle theme"
>
<PaletteIcon />
<span>{THEME_LABELS[getTheme()]}</span>
</Button>
<Button
variant="ghost"
size="sm"
class="justify-start gap-2"
onclick={() => (configured = false)}
title="Server connection settings"
>
<SettingsIcon />
<span>Connection</span>
</Button>
</Sidebar.Footer>
</Sidebar.Root>
<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" />
{#if page === 'chat'}
{@const goalText = $currentTask?.goal ? $currentTask.goal.replace(/[*_`~#]|\[.*?\]\(.*?\)/g, '') : 'New Task'}
<button type="button" class="shrink-0 text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('overview')}>Tasks</button>
<span class="shrink-0 text-muted-foreground">/</span>
<span class="min-w-0 flex-1 truncate text-base font-medium" title={goalText}>
{truncateMiddle(goalText, 100)}
</span>
{:else}
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page === 'kb' ? 'Knowledge Base' : page === 'overview' ? 'Tasks' : page}</span>
{/if}
</header>
<main class="min-h-0 flex-1 overflow-hidden">
{#if page === 'overview'}
<Overview />
{:else if page === 'kb'}
<KnowledgeBase />
{:else if page === 'entity' && routeParam}
<EntityDetail slug={routeParam} />
{:else if page === 'ops'}
<Ops />
{:else if page === 'signals'}
<Signals />
{:else if page === 'knowledge'}
<Knowledge />
{:else if page === 'learning'}
<Learning />
{:else}
<Chat />
{/if}
</main>
</Sidebar.Inset>
</Sidebar.Provider>
<Sheet.Root bind:open={drawerOpen}>
<Sheet.Content side="right" class="w-[400px] p-0 sm:max-w-[400px]">
<Sheet.Header class="sr-only">
<Sheet.Title>Nomos chat</Sheet.Title>
<Sheet.Description>Persistent chat drawer</Sheet.Description>
</Sheet.Header>
<div class="flex h-full flex-col">
<Chat showRail={false} />
</div>
</Sheet.Content>
</Sheet.Root>
<EntityDesktop />
<Toaster />
<Desktop />
{/if}

View File

@@ -289,3 +289,32 @@ a {
a:hover {
text-decoration: underline;
}
/* svelte-splitpanes theming (TaskContextPanel, SessionChatWindow rail) —
mapped onto the app's border/primary tokens instead of the library's
default-theme, so splitters follow the terracotta/dark theme toggle. */
.splitpanes.oikos-theme .splitpanes__pane {
background: transparent;
}
.splitpanes.oikos-theme .splitpanes__splitter {
background-color: transparent;
transition: background-color 0.15s;
}
.splitpanes.oikos-theme .splitpanes__splitter:hover,
.splitpanes.oikos-theme .splitpanes__splitter.splitpanes__splitter__active {
background-color: color-mix(in oklab, var(--primary) 30%, transparent);
}
.oikos-theme.splitpanes--horizontal > .splitpanes__splitter {
height: 6px;
border-bottom: 1px solid var(--border);
cursor: row-resize;
}
.oikos-theme.splitpanes--vertical > .splitpanes__splitter {
width: 6px;
border-left: 1px solid var(--border);
cursor: col-resize;
}

View File

@@ -50,6 +50,21 @@ export async function fetchMessages(sessionId: string): Promise<Message[]> {
return data.messages ?? []
}
// null distinguishes "session doesn't exist" (404 — the session was deleted,
// or a persisted/deep-linked window id was never valid) from a transient
// fetch failure, which should keep returning []/retrying rather than
// permanently flip a window into a "not found" state. Only the initial
// per-window load (chat.ts's loadSessionChat) needs this distinction — the
// polling loops keep using fetchMessages, where swallowing a blip into []
// and trying again next tick is the right behavior.
export async function fetchMessagesOrNotFound(sessionId: string): Promise<Message[] | null> {
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`)
if (res.status === 404) return null
if (!res.ok) return []
const data = await res.json()
return data.messages ?? []
}
export async function deleteSession(sessionId: string): Promise<boolean> {
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
return res.ok
@@ -213,6 +228,24 @@ export async function fetchEntities(filters: EntityFilters = {}): Promise<Entity
return data.items ?? []
}
// The entities endpoint caps at 200/page — the Knowledge Base wants the
// whole set (it filters by type/search client-side now instead of scoping
// the fetch server-side), so page through via cursor until exhausted.
export async function fetchAllEntities(): Promise<Entity[]> {
const all: Entity[] = []
let cursor: string | undefined
do {
const params = new URLSearchParams({ limit: '200' })
if (cursor) params.set('cursor', cursor)
const res = await fetchWithAuth(`${API}/entities?${params}`)
if (!res.ok) break
const data = await res.json()
all.push(...(data.items ?? []))
cursor = data.next_cursor ?? undefined
} while (cursor)
return all
}
export type OntologyLayer = 'meta' | 'infrastructure' | 'governance' | 'cognition'
export interface EntityType {

61
web/src/lib/apps.test.ts Normal file
View File

@@ -0,0 +1,61 @@
import { describe, it, expect, 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: {} }))
import { APPS, appById, appWindowId, appIdFromWindowId } from './apps'
describe('APPS registry', () => {
it('has unique, non-empty ids', () => {
const ids = APPS.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) {
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)
}
expect(appById.size).toBe(APPS.length)
})
})
describe('appWindowId / appIdFromWindowId', () => {
it('round-trips an app id through its window id', () => {
for (const app of APPS) {
expect(appIdFromWindowId(appWindowId(app.id))).toBe(app.id)
}
})
it('returns null for ids that are not app windows', () => {
expect(appIdFromWindowId('session:abc-123')).toBeNull()
expect(appIdFromWindowId('host:strong')).toBeNull()
expect(appIdFromWindowId('new-task')).toBeNull()
})
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) {
expect(appWindowId(app.id).startsWith('app:')).toBe(true)
}
})
})

130
web/src/lib/apps.ts Normal file
View File

@@ -0,0 +1,130 @@
// 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.
import type { Component } from 'svelte'
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 ListTodoIcon from '@lucide/svelte/icons/list-todo'
import DatabaseIcon from '@lucide/svelte/icons/database'
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'
export interface AppDef {
id: string
title: string
icon: Component
component: Component
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
}
export const APPS: AppDef[] = [
{
id: 'tasks',
title: 'Tasks',
icon: ListTodoIcon,
component: Overview,
width: 960,
height: 680,
minWidth: 480,
minHeight: 420
},
{
id: 'kb',
title: 'Knowledge Base',
icon: DatabaseIcon,
component: KnowledgeBase,
width: 1000,
height: 700,
minWidth: 520,
minHeight: 420
},
{
id: 'ops',
title: 'Operations',
icon: ShieldCheckIcon,
component: Ops,
width: 860,
height: 620,
minWidth: 480,
minHeight: 360,
badge: (s) => s?.approvals_pending ?? 0
},
{
id: 'signals',
title: 'Signals',
icon: SirenIcon,
component: Signals,
width: 860,
height: 620,
minWidth: 480,
minHeight: 360,
badge: (s) => openSignalCount(s)
},
{
id: 'knowledge',
title: 'Knowledge',
icon: SearchIcon,
component: Knowledge,
width: 800,
height: 600,
minWidth: 440,
minHeight: 340
},
{
id: 'learning',
title: 'Learning',
icon: TrendingUpIcon,
component: Learning,
width: 800,
height: 600,
minWidth: 440,
minHeight: 340
},
{
id: 'settings',
title: 'Settings',
icon: SettingsIcon,
component: Settings,
width: 640,
height: 480,
minWidth: 480,
minHeight: 360
}
]
export const appById = new Map(APPS.map((a) => [a.id, a]))
// 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>`
// for task chat windows (see windows.ts), anything else is an entity slug.
const APP_PREFIX = 'app:'
export function appWindowId(id: string): string {
return `${APP_PREFIX}${id}`
}
export function appIdFromWindowId(windowId: string): string | null {
return windowId.startsWith(APP_PREFIX) ? windowId.slice(APP_PREFIX.length) : null
}

View File

@@ -3,18 +3,11 @@
// 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.
import type { EntityFilters } from './api'
// 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'
export const categories: { id: Category; label: string }[] = [
{ id: 'fleet', label: 'Fleet' },
{ id: 'network', label: 'Network' },
{ id: 'identity', label: 'Identity' },
{ id: 'knowledge', label: '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
@@ -49,15 +42,3 @@ export function typeToCategory(type: string, domain: string): Category | undefin
if (domain === 'cognition') return undefined
return DOMAIN_TO_CATEGORY[domain]
}
// Filter sets to fetch and merge for a category's table view. Most
// categories are one or two `domain` values; Knowledge is a handful of
// specific `type`s carved out of the (otherwise excluded) cognition domain.
export function filtersForCategory(category: Category): EntityFilters[] {
if (category === 'knowledge') {
return Array.from(KNOWLEDGE_TYPES).map((type) => ({ type }))
}
return Object.entries(DOMAIN_TO_CATEGORY)
.filter(([, c]) => c === category)
.map(([domain]) => ({ domain }))
}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
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'
@@ -11,6 +11,9 @@
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) {
@@ -56,7 +59,7 @@
<div class="flex h-full flex-col">
<div class="flex-1 overflow-y-auto">
{#if $activityLog.length === 0}
{#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" />
@@ -78,8 +81,8 @@
</div>
{:else}
<div class="flex flex-col py-1">
{#each $activityLog as entry, i (entry.id)}
{@const isLast = i === $activityLog.length - 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)}

View File

@@ -0,0 +1,436 @@
<script lang="ts">
// Pure prop-driven transcript + input — no store imports. Both the main
// Chat page (singleton "current session" stores) and a floating task
// window (its own per-session store bundle from chat.ts's chatFor) render
// 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 { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
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'
let {
messages,
streaming,
connectionState,
error = null,
chatErrors = [],
onSend,
onCancel,
onReconnect,
onDismissError,
suggestions = []
}: {
messages: ChatMessage[]
streaming: boolean
connectionState: 'connected' | 'disconnected' | 'reconnecting'
error?: string | null
chatErrors?: { id: string; message: string; action?: string }[]
onSend: (text: string) => void
onCancel: () => void
onReconnect: () => void
onDismissError: (id: string) => void
suggestions?: string[]
} = $props()
let input = $state('')
let messagesEnd = $state<HTMLDivElement | null>(null)
let scrolledUp = $state(false)
let container = $state<HTMLDivElement | null>(null)
// 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
// line-height/padding/border rather than hardcoded, so it stays correct if
// that styling ever changes.
let threadHeight = $state(0)
let textareaRef = $state<HTMLTextAreaElement | null>(null)
let inputWrapperRef = $state<HTMLDivElement | null>(null)
let oneLinePx = $state(64)
$effect(() => {
if (!textareaRef || !inputWrapperRef) return
const taCs = getComputedStyle(textareaRef)
const lineHeight = parseFloat(taCs.lineHeight)
if (!Number.isFinite(lineHeight)) return
const taBoxY =
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.
const wrapperCs = getComputedStyle(inputWrapperRef)
const wrapperBoxY =
parseFloat(wrapperCs.paddingTop) +
parseFloat(wrapperCs.paddingBottom) +
parseFloat(wrapperCs.borderTopWidth) +
parseFloat(wrapperCs.borderBottomWidth)
oneLinePx = lineHeight + taBoxY + wrapperBoxY
})
const inputMinSize = $derived(threadHeight > 0 ? (oneLinePx / threadHeight) * 100 : 12)
let inputSize = $state(12)
let inputSizeDefaulted = false
$effect(() => {
if (!inputSizeDefaulted && threadHeight > 0) {
inputSize = inputMinSize
inputSizeDefaulted = true
}
})
function isNearBottom(): boolean {
if (!container) return true
const { scrollTop, scrollHeight, clientHeight } = container
return scrollHeight - scrollTop - clientHeight < 80
}
function onScroll() {
scrolledUp = !isNearBottom()
}
// Auto-scroll to bottom on new messages — unless user scrolled up to read.
$effect(() => {
void messages
if (streaming || !scrolledUp) {
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
}
})
function render(text: string): string {
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
}
function submit() {
const text = input.trim()
if (!text || streaming) return
input = ''
scrolledUp = false
onSend(text)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
function ask(q: string) {
if (streaming) return
onSend(q)
}
</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">
<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>
{/if}
</div>
{/if}
</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}>
<form
class="relative mx-auto flex h-full w-full max-w-3xl"
onsubmit={(e) => {
e.preventDefault()
submit()
}}
>
<Textarea
bind:ref={textareaRef}
bind:value={input}
onkeydown={handleKeydown}
placeholder="Ask Nomos anything…"
class="h-full max-h-none min-h-0 resize-none rounded-2xl px-4 py-3 pr-12 field-sizing-fixed"
disabled={streaming}
/>
{#if streaming}
<Button
type="button"
size="icon-sm"
variant="secondary"
class="absolute right-2 bottom-2 rounded-lg"
onclick={onCancel}
aria-label="Stop"
>
<SquareIcon class="size-3.5" />
</Button>
{:else}
<Button
type="submit"
size="icon-sm"
variant="secondary"
class="absolute right-2 bottom-2 rounded-lg"
disabled={!input.trim()}
aria-label="Send"
>
<CornerDownLeftIcon class="size-3.5" />
</Button>
{/if}
</form>
</div>
</Pane>
</Splitpanes>
</div>
<style>
/* ── Art Nouveau chat styling ── */
/* Assistant message wrapper */
.assistant-msg {
position: relative;
}
/* User message — soft terracotta bubble, gentle lift */
.user-msg {
box-shadow: 0 1px 8px -4px var(--primary);
}
/* 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-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);
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;
padding: 0.75rem 0.875rem;
overflow-x: auto;
margin: 0 0 0.5rem;
position: relative;
}
.prose-chat :global(pre)::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(to right, transparent, var(--primary), transparent);
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;
margin: 1.15rem 0 0.4rem;
font-size: 1.03em;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(> h1:first-child),
.prose-chat :global(> h2:first-child),
.prose-chat :global(> h3:first-child) {
margin-top: 0;
}
.prose-chat :global(h1)::after,
.prose-chat :global(h2)::after,
.prose-chat :global(h3)::after {
content: '';
display: block;
width: 2.5rem;
height: 2px;
margin-top: 4px;
border-radius: 1px;
background: linear-gradient(to right, var(--primary), transparent);
opacity: 0.55;
}
.prose-chat :global(table) {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.prose-chat :global(th) {
background: var(--muted);
font-weight: 600;
}
.prose-chat :global(th),
.prose-chat :global(td) {
border: 1px solid var(--border);
padding: 0.3rem 0.6rem;
text-align: left;
}
.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;
}
.prose-chat :global(blockquote)::before {
content: '“';
position: absolute;
left: -0.15rem;
top: -0.35rem;
font-size: 1.5rem;
color: var(--primary);
opacity: 0.6;
font-style: normal;
line-height: 1;
}
.prose-chat :global(hr) {
border: none;
height: 1px;
margin: 0.75rem 0;
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
terracotta accent stay meaningful (code, headings, links). */
.prose-chat :global(strong) {
color: var(--foreground);
font-weight: 600;
}
.prose-chat :global(a) {
color: var(--primary);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
/* Input area ornament */
.input-ornament::before {
content: '';
position: absolute;
top: 0;
left: 2rem;
right: 2rem;
height: 1px;
background: linear-gradient(to right, transparent, var(--primary), transparent);
opacity: 0.3;
}
</style>

View File

@@ -1,46 +0,0 @@
<script lang="ts">
// Global floating-window layer — mounted once in App.svelte, above every
// page, so an entity opened from Knowledge Base, chat, or anywhere else
// lands in the same window stack instead of each page owning its own
// single-entity sidebar/sheet. See $lib/stores/windows.ts.
import { dk, wmState, openEntityWindow } from '$lib/stores/windows'
import EntityDetailContent from './EntityDetailContent.svelte'
import XIcon from '@lucide/svelte/icons/x'
import MinusIcon from '@lucide/svelte/icons/minus'
</script>
<div use:dk.desktop class="pointer-events-none fixed inset-0 z-40">
{#each $wmState.order as id (id)}
{@const win = $wmState.windows[id]}
{#if win}
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
<header data-wm-drag class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5">
<span data-wm-title class="min-w-0 flex-1 truncate font-mono text-xs font-medium">{win.title}</span>
<div class="flex shrink-0 items-center gap-0.5">
<button
type="button"
data-wm-minimize
class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Minimize {win.title}"
>
<MinusIcon class="size-3.5" />
</button>
<button
type="button"
data-wm-close
class="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
aria-label="Close {win.title}"
>
<XIcon class="size-3.5" />
</button>
</div>
</header>
<div data-wm-content class="min-h-0 flex-1 overflow-hidden">
{#key id}
<EntityDetailContent slug={id} onSelectEntity={openEntityWindow} />
{/key}
</div>
</section>
{/if}
{/each}
</div>

View File

@@ -1,13 +1,11 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type Simulation } from 'd3-force'
import { fetchGraph, fetchEntityTypes, type GraphView, type Entity, type Health } from '$lib/api'
import { fetchGraph, type GraphView, type Entity, type Health } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { typeToCategory, type Category } from '$lib/categories'
import { Skeleton } from '$lib/components/ui/skeleton'
export interface GraphInfo {
allNodeTypes: string[]
allRelTypes: string[]
relColors: Map<string, string>
visibleCount: number
@@ -16,7 +14,6 @@
}
let {
category,
selectedSlug = null,
onSelect,
root = $bindable(''),
@@ -24,11 +21,12 @@
search,
reloadToken,
resetToken,
activeNodeTypes = $bindable(new Set<string>()),
// 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>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
info = $bindable<GraphInfo>({ allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
}: {
category: Category
selectedSlug?: string | null
onSelect: (slug: string | null) => void
root?: string
@@ -39,7 +37,7 @@
// this resizable pane), so they can't call load()/resetView() directly.
reloadToken: number
resetToken: number
activeNodeTypes?: Set<string>
activeNodeTypes: Set<string>
activeRelTypes?: Set<string>
info?: GraphInfo
} = $props()
@@ -59,19 +57,20 @@
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
// type → browsing category, so the graph can be scoped client-side (the
// graph endpoint itself has no category/domain param). Value is undefined
// for types deliberately excluded from every category (e.g. execution/
// check/task — see categories.ts); the key is still present so inCategory
// can tell "excluded on purpose" apart from "not in the ontology at all."
let typeCategory = $state<Map<string, Category | undefined>>(new Map())
let hoveredId = $state<string | null>(null)
// viewport transform: translate(x, y) scale(k)
@@ -101,7 +100,7 @@
}
function markerId(type: string): string {
return 'arrow-' + type.replace(/[^a-z0-9]/gi, '_')
return `arrow-${uid}-` + type.replace(/[^a-z0-9]/gi, '_')
}
function endpoint(end: string | Node): Node | undefined {
@@ -111,46 +110,7 @@
return typeof end === 'object' ? end.id : end
}
// Node belongs to the active category? Types the ontology never returned
// at all fall back to visible (so a missing entry never blanks the
// graph); types the ontology returned but categories.ts deliberately
// excludes (key present, value undefined) do not.
function inCategory(type: string): boolean {
if (!typeCategory.has(type)) return true
return typeCategory.get(type) === category
}
// Brand-new nodes (no `prev`) get x/y left undefined, and d3-force's
// default init spreads those via a spiral centered on the ORIGIN — not
// (width/2, height/2) — while the x/y centering forces below are
// deliberately weak (0.04, so they don't fight the link/collide layout).
// Together that meant the cluster could settle noticeably off-origin
// instead of centered. Fixed by explicitly fitting the viewport to the
// node bounding box once the simulation settles, rather than relying on
// the force balance to land on center by itself.
function fitToView() {
const placed = nodes.filter((n) => n.x != null && n.y != null)
if (!placed.length) return
const xs = placed.map((n) => n.x as number)
const ys = placed.map((n) => n.y as number)
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
const minY = Math.min(...ys)
const maxY = Math.max(...ys)
const pad = 70
const bw = Math.max(maxX - minX, 1)
const bh = Math.max(maxY - minY, 1)
const k = Math.min((width - pad * 2) / bw, (height - pad * 2) / bh, 2.5)
const cx = (minX + maxX) / 2
const cy = (minY + maxY) / 2
view = { k, x: width / 2 - cx * k, y: height / 2 - cy * k }
}
// fit=false for passive background reloads (live entity/relationship
// events) — those shouldn't yank the view out from under someone
// actively panning/zooming. Fresh loads (mount, root/depth change,
// reset, re-root) default to fit=true.
async function load(fit = true) {
async function load() {
loading = true
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
loading = false
@@ -176,8 +136,8 @@
type: e.type
}))
// Default the node/edge-type toggles to the types present in the active category.
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.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()
@@ -193,17 +153,9 @@
.on('tick', () => {
nodes = [...nodes]
})
.on('end', () => {
if (fit) fitToView()
})
}
onMount(() => {
fetchEntityTypes().then((types) => {
typeCategory = new Map(types.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
// Re-derive the active node types now that category membership is known.
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
})
load()
const unsubscribe = subscribeEvents()
return () => {
@@ -214,17 +166,11 @@
onDestroy(() => sim?.stop())
// When the category perspective changes, reset the node-type toggles to it.
$effect(() => {
category
activeNodeTypes = new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))
})
$effect(() => {
const ev = $liveEvents[0]
if (!ev) return
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
load(false)
load()
}
})
@@ -266,14 +212,11 @@
return 5 + Math.min(Math.sqrt(node.degree) * 1.6, 7)
}
// Only offer node-type toggles that live in the active category.
const allNodeTypes = $derived(Array.from(new Set(nodes.filter((n) => inCategory(n.type)).map((n) => n.type))).sort())
const allRelTypes = $derived(Array.from(new Set(links.map((l) => l.type))).sort())
// Publish status/legend info up to the parent toolbar.
$effect(() => {
info = {
allNodeTypes,
allRelTypes,
relColors: relColorByType,
visibleCount: visibleNodeIds.size,
@@ -288,21 +231,21 @@
return new Set(nodes.filter((n) => n.slug.toLowerCase().includes(q) || n.name.toLowerCase().includes(q)).map((n) => n.id))
})
// Focus = in the active category AND its node-type toggle is on — these
// are what the category tab is "about."
const focusNodeIds = $derived(new Set(nodes.filter((n) => inCategory(n.type) && activeNodeTypes.has(n.type)).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 category lines (a service sits on
// a network, uses storage, runs on an lxc — different categories under
// this taxonomy). Hard-hiding any edge whose other end isn't in-category
// left focus nodes looking like disconnected dots. Rooted views (the user
// is exploring out from one entity) pull in 1-hop neighbors of any
// category, dimmed, so the edges — and what they connect to — stay
// visible. Unscoped "browse the whole category" views (no root) skip
// this: with ~50 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 view) — worse than the isolated-dot
// problem it was meant to fix. There, same-category-only edges stay.
// 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
@@ -452,7 +395,7 @@
onpointercancel={onPointerUp}
>
<defs>
<pattern id="dot-grid" width="12" height="12" patternUnits="userSpaceOnUse">
<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}
@@ -461,7 +404,7 @@
</marker>
{/each}
</defs>
<rect x="0" y="0" width={width} height={height} fill="url(#dot-grid)" />
<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}
@@ -472,25 +415,31 @@
{@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 - (dx / len) * tr}
{@const ey = t.y - (dy / len) * tr}
<line
x1={s.x}
y1={s.y}
x2={ex}
y2={ey}
{@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>
</line>
</path>
{#if vs.emphasized && view.k >= 0.7}
<text
x={(s.x + ex) / 2}
y={(s.y + ey) / 2 - 4}
x={mx}
y={my - 4}
text-anchor="middle"
font-size={10 / view.k}
fill={relColor(link.type)}

View File

@@ -198,8 +198,14 @@
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.lineTo(b.x, b.y)
ctx.quadraticCurveTo(mx, my, b.x, b.y)
}
ctx.stroke()

View File

@@ -1,17 +1,18 @@
<script lang="ts">
import { openQuestion } from '$lib/stores/workspace'
import { currentSession } from '$lib/stores/chat'
import { answerQuestion as postAnswer } from '$lib/api'
import { answerQuestion as postAnswer, type SessionQuestion } from '$lib/api'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
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 freeText = $state('')
let submitting = $state(false)
async function submit(answer: string) {
const sid = $currentSession
const q = $openQuestion
const sid = sessionId
const q = question
if (!sid || !q || !answer.trim() || submitting) return
submitting = true
const ok = await postAnswer(sid, q.id, answer.trim())
@@ -23,8 +24,8 @@
}
</script>
{#if $openQuestion}
{@const q = $openQuestion}
{#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 items-start gap-2">
<CircleHelpIcon class="mt-0.5 size-4 shrink-0 text-warning" />

View File

@@ -0,0 +1,67 @@
<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
// 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.
import { onDestroy, onMount } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { chatFor, loadSessionChat, sendSessionMessage, cancelSessionStream, stopSessionPolling, dismissError, chatErrors } from '$lib/stores/chat'
import ChatThread from '$lib/components/ChatThread.svelte'
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
let { sessionId }: { sessionId: string } = $props()
// 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.
const chat = chatFor(sessionId)
const chatMessages = chat.messages
const chatStreaming = chat.streaming
const chatConnectionState = chat.connectionState
const chatError = chat.error
const chatNotFound = chat.notFound
let loading = $state(true)
onMount(async () => {
await loadSessionChat(sessionId)
loading = false
})
onDestroy(() => stopSessionPolling(sessionId))
// Resizable right rail — sized smaller by default since task windows open
// narrower than the full page.
let railSize = $state(24)
</script>
<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>
{: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}
<Splitpanes theme="oikos-theme" dblClickSplitter={false}>
<Pane>
<ChatThread
messages={$chatMessages}
streaming={$chatStreaming}
connectionState={$chatConnectionState}
error={$chatError}
chatErrors={$chatErrors}
onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)}
onDismissError={dismissError}
/>
</Pane>
<Pane bind:size={railSize} minSize={18} maxSize={40}>
<TaskContextPanel {sessionId} />
</Pane>
</Splitpanes>
{/if}
</div>

View File

@@ -11,10 +11,22 @@
type Simulation
} from 'd3-force'
import { fetchGraph, type Entity } from '$lib/api'
import { messages } from '$lib/stores/chat'
import { touched, healthDiffs } from '$lib/stores/workspace'
import type { ChatMessage } from '$lib/stores/chat'
import type { TouchedEntity, HealthDiff } from '$lib/stores/workspace'
import { openEntityWindow, wmState } from '$lib/stores/windows'
// 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()
// 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
// per-instance suffix every one of them would define (and reference)
// <pattern id="dot-grid">, so only the first in the document would ever
// actually paint (the rest resolve to nothing, background reads blank).
const dotGridId = `dot-grid-${crypto.randomUUID().slice(0, 8)}`
interface Node extends Entity {
x?: number
y?: number
@@ -75,7 +87,7 @@
// 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) {
for (const m of messages) {
collectSlugs(m.text, out)
for (const t of m.tools) collectSlugs(t.args, out)
}
@@ -203,7 +215,7 @@
})
// The highlight ring/dim styling below is tied to the node whose window
// was last opened — once that window is closed (from EntityDesktop, not
// was last opened — once that window is closed (from WindowLayer, not
// necessarily from here), the ring should go with it rather than pointing
// at a window that no longer exists.
$effect(() => {
@@ -234,15 +246,15 @@
// object identity fine and this is small (≤12 touched, ≤8 diffs).
const touchedBySlug = $derived.by(() => {
const m: Record<string, true> = {}
for (const t of $touched) m[t.slug] = true
for (const t of touched) m[t.slug] = true
return m
})
const diffBySlug = $derived.by(() => {
const m: Record<string, { from: string; to: string }> = {}
for (const d of $healthDiffs) if (!(d.slug in m)) m[d.slug] = d
for (const d of healthDiffs) if (!(d.slug in m)) m[d.slug] = d
return m
})
const nowTouching = $derived($touched[0] ?? null)
const nowTouching = $derived(touched[0] ?? null)
function endpoint(end: string | Node): Node | undefined {
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
@@ -253,7 +265,7 @@
// ─── drag / select ───────────────────────────────────────────────────
// A click (pointerdown+up with no movement in between) opens the entity
// straight in its own floating window (EntityDesktop) instead of a
// straight in its own floating window (WindowLayer) instead of a
// click-through mini-panel — `selected` now only drives the highlight/dim
// styling below, so you can see at a glance which node you last opened.
let dragState: { node: Node; moved: boolean } | null = null
@@ -348,28 +360,32 @@
onpointercancel={onUp}
>
<defs>
<pattern id="dot-grid" width="12" height="12" patternUnits="userSpaceOnUse">
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
</pattern>
</defs>
<rect width={cw} height={ch} fill="url(#dot-grid)" />
<rect width={cw} height={ch} fill="url(#{dotGridId})" />
<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}
{@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}
<path
d="M {s.x},{s.y} Q {cx},{cy} {t.x},{t.y}"
fill="none"
stroke="var(--muted-foreground)"
stroke-width={focus ? 1.6 : 1}
opacity={selected ? (focus ? 0.7 : 0.12) : 0.35}
>
<title>{link.type}</title>
</line>
</path>
{/if}
{/each}
</g>

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte'
import { startWorkspace, planSteps, currentTask, touched } from '$lib/stores/workspace'
import { activityLog } from '$lib/stores/activity'
import { streaming } from '$lib/stores/chat'
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 { activityLog, activityLogFor } from '$lib/stores/activity'
import OperatorQuestion from './OperatorQuestion.svelte'
import SessionGraph from './SessionGraph.svelte'
import ActivityTimeline from './ActivityTimeline.svelte'
@@ -16,50 +17,60 @@
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
import CirclePauseIcon from '@lucide/svelte/icons/circle-pause'
onMount(() => startWorkspace())
// 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".
let { sessionId = null }: { sessionId?: string | null } = $props()
onMount(() => (sessionId ? startSessionWorkspace(sessionId) : 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)
const chat = $derived(sessionId ? chatFor(sessionId) : null)
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: distribute height across the 3 content areas.
// Heights stored in px, minus header sizes. Default even split.
let heights = $state([300, 200, 200])
let resizing = $state(-1)
let resizeStartY = $state(0)
let resizeStartH = $state<[number, number]>([0, 0])
// Resize: each section is a Pane in one vertical Splitpanes. Sizes are
// percentages of the panel's height; undefined means "share the space
// evenly with the other auto sections". Collapsing a section pins it to
// COLLAPSED_SIZE (roughly a header's worth of height) and remembers its
// last size so reopening restores it.
const COLLAPSED_SIZE = 6
const OPEN_MIN_SIZE = 12
let sizes = $state<(number | undefined)[]>([undefined, undefined, undefined])
// 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]
function onPointerDown(i: number, e: PointerEvent) {
e.preventDefault()
resizing = i
resizeStartY = e.clientY
resizeStartH = [heights[i], heights[i + 1]]
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
}
function onPointerMove(e: PointerEvent) {
if (resizing < 0) return
const dy = e.clientY - resizeStartY
const minH = 80
// Clamp each section to minimum
const newA = Math.max(minH, resizeStartH[0] + dy)
const newB = Math.max(minH, resizeStartH[1] - dy)
const newHeights = [...heights]
newHeights[resizing] = newA
newHeights[resizing + 1] = newB
heights = newHeights
}
function onPointerUp() {
resizing = -1
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
function toggleSection(i: number, isOpen: boolean) {
if (isOpen) {
savedSizes[i] = sizes[i] ?? savedSizes[i]
sizes[i] = COLLAPSED_SIZE
} else {
sizes[i] = savedSizes[i]
}
}
// Plan collapsed status
const planDone = $derived($planSteps.filter((s) => s.status === 'done').length)
const planTotal = $derived($planSteps.length)
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
@@ -67,72 +78,72 @@
// 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 = $currentTask?.status
const st = $taskStore?.status
if (st === 'done' || st === 'failed' || st === 'abandoned') return 'none'
if (st === 'planning' || $streaming) return 'drafting'
if (st === 'planning' || $streamingStore) return 'drafting'
return 'idle'
})
// Activity collapsed status
const activityRunning = $derived($activityLog.filter((e) => e.status === 'running').length)
const activityCount = $derived($activityLog.length)
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 />
<OperatorQuestion sessionId={effectiveSessionId} question={$openQuestionStore} />
<!-- Scope -->
<div class="flex shrink-0 flex-col border-b">
<button
type="button"
class="flex 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={() => (scopeOpen = !scopeOpen)}
>
{#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">{$touched.length ? `${$touched.length} entit${$touched.length === 1 ? 'y' : 'ies'}` : 'Graph'}</span>
{/if}
</button>
{#if scopeOpen}
<div style="height: {heights[0]}px">
<SessionGraph />
</div>
<!-- resize handle -->
<div
class="h-1.5 cursor-row-resize border-b hover:bg-primary/30 touch-none"
onpointerdown={(e) => onPointerDown(0, e)}
role="separator"
aria-orientation="horizontal"
></div>
{/if}
</div>
<!-- Plan -->
<div class="flex shrink-0 flex-col border-b">
<button
type="button"
class="flex 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={() => (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 $currentTask?.goal}
<span class="ml-auto max-w-[120px] truncate font-normal normal-case">{$currentTask.goal}</span>
{:else}
<span class="ml-auto font-normal normal-case text-muted-foreground">No plan yet</span>
<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">
<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(0, scopeOpen)
scopeOpen = !scopeOpen
}}
>
{#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>
{/if}
</button>
{#if scopeOpen}
<div class="min-h-0 flex-1">
<SessionGraph messages={$messagesStore} touched={$touchedStore} healthDiffs={$healthDiffsStore} />
</div>
{/if}
</button>
{#if planOpen}
<div style="height: {heights[1]}px" class="flex flex-col overflow-y-auto">
{#if $currentTask?.goal}
</Pane>
<!-- Plan -->
<Pane bind:size={sizes[1]} minSize={planOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE} maxSize={planOpen ? 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">{$currentTask.goal}</span>
<span class="text-xs leading-snug text-foreground/90">{$taskStore.goal}</span>
</div>
{/if}
{#if planTotal > 0}
@@ -146,11 +157,11 @@
</div>
</div>
<ol class="flex flex-col overflow-y-auto px-2 pb-2 text-[11px]">
{#each $planSteps as step, i (step.id)}
{#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 < $planSteps.length - 1}
{#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">
@@ -222,38 +233,35 @@
</div>
{/if}
</div>
<!-- resize handle -->
<div
class="h-1.5 cursor-row-resize border-b hover:bg-primary/30 touch-none"
onpointerdown={(e) => onPointerDown(1, e)}
role="separator"
aria-orientation="horizontal"
></div>
{/if}
</div>
<!-- Activity -->
<div class="flex min-h-0 flex-1 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={() => (activityOpen = !activityOpen)}
>
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon class="size-3" />{/if}
<span>Event log</span>
{#if !activityOpen}
{#if $streaming && activityRunning > 0}
<Spinner class="size-3 text-primary" />
<span class="font-normal normal-case text-primary">{activityRunning} running</span>
{:else}
<span class="ml-auto font-normal normal-case">{activityCount || '—'} action{activityCount === 1 ? '' : 's'}</span>
{/if}
{/if}
</button>
{#if activityOpen}
<div class="min-h-0 flex-1 overflow-hidden">
<ActivityTimeline />
</div>
{/if}
</div>
</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)
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>
{:else}
<span class="ml-auto font-normal normal-case">{activityCount || '—'} action{activityCount === 1 ? '' : 's'}</span>
{/if}
{/if}
</button>
{#if activityOpen}
<div class="min-h-0 flex-1 overflow-hidden">
<ActivityTimeline entries={$activityLogStore} />
</div>
{/if}
</Pane>
</Splitpanes>
</div>

View File

@@ -0,0 +1,161 @@
<script lang="ts">
// The desktop shell: full-viewport surface (background + icons + the
// centered task launcher + the floating window layer) with the taskbar
// docked below it as a real flex sibling, not an overlay — so a maximized
// 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 { 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 DesktopIcon from './DesktopIcon.svelte'
import TaskLauncher from './TaskLauncher.svelte'
import WindowLayer from './WindowLayer.svelte'
import Taskbar from './Taskbar.svelte'
import LayersIcon from '@lucide/svelte/icons/layers'
import Rows3Icon from '@lucide/svelte/icons/rows-3'
import MonitorIcon from '@lucide/svelte/icons/monitor'
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
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)
let menuCanUndo = $state(false)
let menuCanRedo = $state(false)
function onSurfaceContextMenu(e: MouseEvent) {
if (e.currentTarget !== e.target) return
e.preventDefault()
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,
// close, ...) — wmkit tracks this history but ships no default keybinding.
// Skipped entirely while an editable element has focus so it never
// 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)
if (editable) return
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'z') return
e.preventDefault()
if (e.shiftKey) wm.redo()
else wm.undo()
}
</script>
<svelte:window onkeydown={onWindowKeydown} onclick={closeMenu} />
<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="pointer-events-none absolute inset-0 z-0">
{#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)} />
{/each}
</div>
<div class="pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-6">
<div class="pointer-events-auto">
<TaskLauncher />
</div>
</div>
<WindowLayer />
</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}

View File

@@ -0,0 +1,100 @@
<script lang="ts">
// A single desktop icon: positioned from the grid store, draggable to any
// free cell, opens its app on a plain click. wmkit has nothing to do with
// icons — they're a flat non-overlapping grid, not floating/resizable
// windows, so this is a small self-contained pointer-drag implementation
// rather than pressing wmkit's window abstractions into a shape they don't
// fit. See $lib/stores/icons.ts for the grid model + persistence.
import { GRID, iconPixelPos, placeIcon, type IconPos } from '$lib/stores/icons'
import type { AppDef } from '$lib/apps'
let {
app,
pos,
badge = 0,
onOpen
}: { app: AppDef; pos: IconPos; badge?: number; onOpen: () => void } = $props()
const DRAG_THRESHOLD = 5
let dragging = $state(false)
let dragPos = $state<{ x: number; y: number } | null>(null)
function toCell(x: number, y: number): IconPos {
return {
col: Math.round((x - GRID.padding) / (GRID.cell + GRID.gap)),
row: Math.round((y - GRID.padding) / (GRID.cell + GRID.gap))
}
}
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return
const el = e.currentTarget as HTMLElement
const startX = e.clientX
const startY = e.clientY
const origin = iconPixelPos(pos)
let moved = false
el.setPointerCapture(e.pointerId)
function onMove(ev: PointerEvent) {
const dx = ev.clientX - startX
const dy = ev.clientY - startY
if (!moved && Math.hypot(dx, dy) > DRAG_THRESHOLD) {
moved = true
dragging = true
}
if (moved) {
dragPos = { x: origin.x + dx, y: origin.y + dy }
}
}
function onUp() {
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', onUp)
if (moved && dragPos) {
const cell = toCell(dragPos.x, dragPos.y)
placeIcon(app.id, cell.col, cell.row)
} else {
onOpen()
}
dragging = false
dragPos = null
}
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', onUp)
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onOpen()
}
}
const restPos = $derived(iconPixelPos(pos))
const left = $derived(dragging && dragPos ? dragPos.x : restPos.x)
const top = $derived(dragging && dragPos ? dragPos.y : restPos.y)
</script>
<button
type="button"
class="group absolute flex flex-col items-center gap-1 rounded-lg p-1.5 pointer-events-auto select-none focus-visible:outline-2 focus-visible:outline-ring {dragging
? 'z-50 cursor-grabbing bg-accent/40'
: 'cursor-pointer hover:bg-accent/30'}"
style="left: {left}px; top: {top}px; width: {GRID.cell}px;"
onpointerdown={onPointerDown}
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">
<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">
{badge > 99 ? '99+' : badge}
</span>
{/if}
</span>
<span class="max-w-full truncate text-[11px] text-foreground/90">{app.title}</span>
</button>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
// "What should Nomos do?" — the desktop's centerpiece. Extracted from the
// old Overview page hero so both the desktop surface and the Tasks app
// window can mount it; startTask() (see $lib/stores/chat.ts) begins the
// stream immediately and hands back the session id once the backend
// assigns one, which is the earliest point a task window can be opened.
import { startTask } from '$lib/stores/chat'
import { openTaskWindow } from '$lib/stores/windows'
import { truncateMiddle } from '$lib/utils'
import { Textarea } from '$lib/components/ui/textarea'
import { Button } from '$lib/components/ui/button'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
let { compact = false, onStarted }: { compact?: boolean; onStarted?: () => void } = $props()
let input = $state('')
function submit() {
const text = input.trim()
if (!text) return
input = ''
startTask(text, (sessionId) => openTaskWindow(sessionId, truncateMiddle(text, 60)))
onStarted?.()
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
</script>
<div class="w-full max-w-2xl text-center">
{#if !compact}
<h1 class="mb-1 text-2xl font-semibold tracking-tight">What should Nomos do?</h1>
<p class="mb-4 text-sm text-muted-foreground">
Describe a goal — Nomos will plan it, execute it, and report the outcome.
</p>
{/if}
<form
class="relative rounded-2xl border bg-card/70 shadow-lg backdrop-blur focus-within:border-primary/60"
onsubmit={(e) => {
e.preventDefault()
submit()
}}
>
<Textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="e.g. Roll the staging database back to last night's snapshot and verify the app is healthy…"
rows={compact ? 2 : 3}
class="max-h-52 min-h-24 resize-none border-0 bg-transparent px-4 py-3.5 text-base shadow-none focus-visible:ring-0"
/>
<div class="flex items-center justify-between px-3 pb-3">
<span class="text-[11px] text-muted-foreground">Enter to start · Shift+Enter for newline</span>
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Start task">
<ArrowUpIcon />
</Button>
</div>
</form>
</div>

View File

@@ -0,0 +1,129 @@
<script lang="ts">
// Bottom taskbar: a real flex row in the page layout (not an overlay), so
// windows can never be dragged/maximized underneath it — see Desktop.svelte
// for how the window layer's bounds are scoped to the surface above this.
// Shows a button for every open window (not just minimized ones — compare
// to the old MinimizedWindowsBar, which only ever showed minimized windows
// and gave no way to see/switch between windows that were merely
// unfocused), plus a system tray for theme/connection/version.
import { wm, wmState, toggleShowDesktop, openAppWindow } from '$lib/stores/windows'
import { appById, appIdFromWindowId } from '$lib/apps'
import { summary } from '$lib/stores/context'
import { truncateMiddle } from '$lib/utils'
import { getTheme, toggleTheme, THEME_LABELS } from '$lib/stores/theme.svelte'
import { VERSION } from '$lib/version'
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
import DatabaseIcon from '@lucide/svelte/icons/database'
import PaletteIcon from '@lucide/svelte/icons/palette'
import SettingsIcon from '@lucide/svelte/icons/settings'
import LayoutGridIcon from '@lucide/svelte/icons/layout-grid'
import XIcon from '@lucide/svelte/icons/x'
const SESSION_PREFIX = 'session:'
const buttons = $derived(
[...$wmState.order]
.map((id) => $wmState.windows[id])
.filter((w): w is NonNullable<typeof w> => !!w)
.sort((a, b) => a.openedSeq - b.openedSeq)
)
function iconFor(id: string) {
const appId = appIdFromWindowId(id)
if (appId) return appById.get(appId)?.icon
if (id.startsWith(SESSION_PREFIX)) return MessageSquareIcon
return DatabaseIcon
}
function badgeFor(id: string): number {
const appId = appIdFromWindowId(id)
const app = appId ? appById.get(appId) : undefined
return app?.badge?.($summary) ?? 0
}
function toggle(id: string, win: (typeof buttons)[number]) {
if (win.stage === 'minimized') {
wm.restore(id)
wm.focus(id)
} else if ($wmState.focusedId === id) {
wm.minimize(id)
} else {
wm.focus(id)
}
}
</script>
<div class="flex h-11 shrink-0 items-center gap-1.5 border-t bg-muted/30 px-2">
<button
type="button"
class="flex shrink-0 items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={toggleShowDesktop}
title="Show desktop"
>
<LayoutGridIcon class="size-4" />
</button>
<div class="h-6 w-px shrink-0 bg-border"></div>
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
{#each buttons as win (win.id)}
{@const Icon = iconFor(win.id)}
{@const badge = badgeFor(win.id)}
<div class="group/tb relative flex shrink-0">
<button
type="button"
data-taskbar-btn={win.id}
class="flex h-8 max-w-56 items-center gap-1.5 rounded-md border px-2 font-mono text-xs transition-colors {$wmState.focusedId ===
win.id && win.stage !== 'minimized'
? 'border-primary/50 bg-primary/10 text-foreground'
: 'border-transparent bg-card/60 text-muted-foreground hover:bg-muted'} {win.stage === 'minimized' ? 'opacity-60' : ''}"
onclick={() => toggle(win.id, win)}
title={win.title}
>
{#if Icon}<Icon class="size-3.5 shrink-0" />{/if}
<span class="min-w-0 truncate">{truncateMiddle(win.title, 26)}</span>
{#if badge > 0}
<span class="flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-semibold text-destructive-foreground">
{badge > 99 ? '99+' : badge}
</span>
{/if}
</button>
<button
type="button"
class="absolute -top-1.5 -right-1.5 hidden size-4 items-center justify-center rounded-full bg-muted-foreground/80 text-background hover:bg-destructive group-hover/tb:flex"
onclick={(e) => {
e.stopPropagation()
wm.close(win.id)
}}
aria-label="Close {win.title}"
>
<XIcon class="size-2.5" />
</button>
</div>
{/each}
</div>
<div class="h-6 w-px shrink-0 bg-border"></div>
<div class="flex shrink-0 items-center gap-0.5">
<button
type="button"
class="flex items-center justify-center gap-1.5 rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={() => toggleTheme()}
title="Cycle theme"
>
<PaletteIcon class="size-4" />
<span class="hidden text-xs sm:inline">{THEME_LABELS[getTheme()]}</span>
</button>
<button
type="button"
class="flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={() => openAppWindow('settings')}
title="Settings"
>
<SettingsIcon class="size-4" />
</button>
<span class="px-1.5 text-[11px] text-muted-foreground select-none">{VERSION}</span>
</div>
</div>

View File

@@ -0,0 +1,89 @@
<script lang="ts">
// The floating-window layer — mounted once inside Desktop.svelte, above the
// icons layer, so every window (an app, a task, an entity detail) shares
// one stack instead of each page owning its own single-entity
// sidebar/sheet. See $lib/stores/windows.ts. Content is resolved purely
// from the window's id, which is why persisted/hydrated windows (see
// wmPersist in windows.ts) need no extra bookkeeping to know what to render:
// app:<id> -> registry component (windows.ts openAppWindow)
// session:<id> -> SessionChatWindow (windows.ts openTaskWindow)
// new-task -> TaskLauncher (windows.ts openNewTaskWindow)
// anything else -> entity slug -> EntityDetailContent
import { wm, dk, wmState, openEntityWindow, NEW_TASK_WINDOW_ID } from '$lib/stores/windows'
import { appById, appIdFromWindowId } from '$lib/apps'
import EntityDetailContent from '../EntityDetailContent.svelte'
import SessionChatWindow from '../SessionChatWindow.svelte'
import TaskLauncher from './TaskLauncher.svelte'
import XIcon from '@lucide/svelte/icons/x'
import MinusIcon from '@lucide/svelte/icons/minus'
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
const SESSION_PREFIX = 'session:'
// A hydrated `app:<id>` window whose id no longer matches any registry
// entry (the app was renamed/removed since the layout was persisted) has
// nothing to render — close it rather than leaving a permanently-blank
// window stuck in the taskbar.
$effect(() => {
for (const id of $wmState.order) {
const appId = appIdFromWindowId(id)
if (appId && !appById.has(appId)) wm.close(id)
}
})
</script>
<div use:dk.desktop class="absolute inset-0 z-40 pointer-events-none">
{#each $wmState.order as id (id)}
{@const win = $wmState.windows[id]}
{@const appId = appIdFromWindowId(id)}
{@const app = appId ? appById.get(appId) : undefined}
{#if win && (!appId || app)}
<section use:dk.window={{ id }} class="min-w-0" aria-label={win.title}>
<header data-wm-drag class="flex shrink-0 cursor-move items-center justify-between gap-2 border-b bg-muted/40 px-3 py-1.5">
<span data-wm-title class="flex min-w-0 flex-1 items-center self-stretch truncate font-mono text-xs font-medium">{win.title}</span>
<div class="flex shrink-0 items-center gap-0.5">
<button
type="button"
data-wm-minimize
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Minimize {win.title}"
>
<MinusIcon class="size-3.5" />
</button>
<button
type="button"
data-wm-maximize
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Maximize {win.title}"
>
<Maximize2Icon class="size-3.5" />
</button>
<button
type="button"
data-wm-close
class="flex items-center justify-center rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
aria-label="Close {win.title}"
>
<XIcon class="size-3.5" />
</button>
</div>
</header>
<div data-wm-content class="min-h-0 flex-1 overflow-hidden">
{#key id}
{#if id.startsWith(SESSION_PREFIX)}
<SessionChatWindow sessionId={id.slice(SESSION_PREFIX.length)} />
{:else if id === NEW_TASK_WINDOW_ID}
<div class="flex h-full items-center justify-center p-6">
<TaskLauncher onStarted={() => wm.close(NEW_TASK_WINDOW_ID)} />
</div>
{:else if app}
<app.component />
{:else}
<EntityDetailContent slug={id} onSelectEntity={openEntityWindow} />
{/if}
{/key}
</div>
</section>
{/if}
{/each}
</div>

View File

@@ -0,0 +1,4 @@
// Shim for SvelteKit's `$app/environment` module — this is a plain Vite app,
// not SvelteKit, but svelte-splitpanes imports `browser` from it for its
// browser-detection utility. Aliased in vite.config.ts.
export const browser = typeof window !== 'undefined'

View File

@@ -1,6 +1,7 @@
import { derived } from 'svelte/store'
import { messages, type ToolCallResult } from './chat'
import { planSteps, currentTask } from './workspace'
import { derived, type Readable } from 'svelte/store'
import { messages, chatFor, type ChatMessage, type ToolCallResult } from './chat'
import { planSteps, currentTask, workspaceFor, taskFor } from './workspace'
import type { PlanStep, Session } from '$lib/api'
export { type ToolCallResult }
@@ -39,7 +40,10 @@ function stringifyResult(result: unknown): string {
return s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n… truncated` : s
}
export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) => {
// Pure derivation, parameterized so it can back both the global "current
// session" activityLog below and a per-session activityLogFor(sessionId) for
// a floating task window.
function computeActivityLog($msgs: ChatMessage[], $steps: PlanStep[], $task: Session | null): ActivityEntry[] {
const entries: ActivityEntry[] = []
const now = Date.now()
@@ -168,7 +172,18 @@ export const activityLog = derived([messages, planSteps, currentTask], ([$msgs,
entries.sort((a, b) => a.timestamp - b.timestamp)
return entries
})
}
export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) =>
computeActivityLog($msgs, $steps, $task)
)
export function activityLogFor(sessionId: string): Readable<ActivityEntry[]> {
const chat = chatFor(sessionId)
const ws = workspaceFor(sessionId)
const task = taskFor(sessionId)
return derived([chat.messages, ws.planSteps, task], ([$msgs, $steps, $task]) => computeActivityLog($msgs, $steps, $task))
}
function toolActivityLabel(t: ToolCallResult): string {
const args = t.args ?? {}

View File

@@ -1,5 +1,5 @@
import { writable, get } from 'svelte/store'
import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
import { writable, get, type Writable } from 'svelte/store'
import { streamChat, fetchSessions, fetchMessages, fetchMessagesOrNotFound, deleteSession as apiDeleteSession } from '$lib/api'
import type { ChatEvent, Session, Message } from '$lib/api'
import type { ToolCallResult } from '$lib/types'
@@ -481,3 +481,301 @@ export async function deleteSession(sessionId: string) {
}
loadSessions()
}
// ─── per-session chat state, for floating task windows ─────────────────────
//
// Everything above this point is the single "whatever's on screen" view used
// by the main Chat page and the chat drawer — one global `currentSession`,
// one `messages` array, guarded so a background stream never clobbers the
// view. Floating task windows break that assumption: several sessions can be
// open and legitimately streaming at once, each wanting its own live
// transcript. Rather than retrofit the guard-heavy logic above (streamed
// events checking `get(currentSession) === streamSessionID` before applying),
// each window gets its own isolated store bundle keyed by session id, so
// there's nothing to guard — events for session X always land in X's own
// bundle regardless of what else is open or on screen.
export interface SessionChatState {
messages: Writable<ChatMessage[]>
streaming: Writable<boolean>
connectionState: Writable<'connected' | 'disconnected' | 'reconnecting'>
error: Writable<string | null>
// Set by loadSessionChat when the backend 404s the session outright
// (deleted, or an id that was never valid — a stale persisted window, a
// bad deep link). Distinct from a merely-empty transcript, which is the
// normal state for a session that exists but hasn't sent a message yet.
notFound: Writable<boolean>
}
const sessionChats = new Map<string, SessionChatState>()
const sessionPollers = new Map<string, ReturnType<typeof setInterval>>()
// Lazily creates (and memoizes) the store bundle for a session — call this to
// get the stores to subscribe to; it does not fetch anything.
export function chatFor(sessionId: string): SessionChatState {
let c = sessionChats.get(sessionId)
if (!c) {
c = {
messages: writable([]),
streaming: writable(false),
connectionState: writable('connected'),
error: writable(null),
notFound: writable(false)
}
sessionChats.set(sessionId, c)
}
return c
}
function startSessionPolling(sessionId: string) {
const existing = sessionPollers.get(sessionId)
if (existing) clearInterval(existing)
const chat = chatFor(sessionId)
sessionPollers.set(
sessionId,
setInterval(async () => {
if (get(chat.streaming) && get(chat.connectionState) === 'connected') return
const msgs = await fetchMessages(sessionId)
if (get(chat.streaming)) return // re-check: the fetch itself takes time
chat.messages.set(toChatMessages(msgs))
}, 3000)
)
}
export function stopSessionPolling(sessionId: string) {
const t = sessionPollers.get(sessionId)
if (t) {
clearInterval(t)
sessionPollers.delete(sessionId)
}
}
// Fetches sessionId's current transcript into its own store bundle and
// starts polling it for auto-continuation updates — the per-session
// equivalent of loadSessionMessages, for a window rather than the main view.
export async function loadSessionChat(sessionId: string): Promise<void> {
const chat = chatFor(sessionId)
chat.streaming.set(false)
const msgs = await fetchMessagesOrNotFound(sessionId)
if (msgs === null) {
chat.notFound.set(true)
return // nothing to poll — the session doesn't exist
}
chat.messages.set(toChatMessages(msgs))
startSessionPolling(sessionId)
}
// Per-session equivalent of sendMessage — writes into sessionId's own store
// bundle unconditionally (no "is this still on screen" guard needed, since
// the bundle IS the screen for this session's window) and shares
// `activeControllers` with the singleton path above so cancelStream() from
// either a window or the main view (if the same session happens to be open
// in both) finds the same in-flight call.
export function sendSessionMessage(sessionId: string, text: string) {
const chat = chatFor(sessionId)
chat.error.set(null)
chat.streaming.set(true)
const userMsg: ChatMessage = { id: mid(), role: 'user', text, tools: [], pendingApprovals: [] }
chat.messages.update((ms) => [...ms, userMsg])
const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] }
chat.messages.update((ms) => [...ms, assistantMsg])
let activeTools: Map<string, ToolCallResult> = new Map()
let receivedDone = false
const controller = streamChat(
text,
sessionId,
(ev: ChatEvent) => {
if (ev.type === 'session') return // sessionId is already known for a window
if (ev.type === 'tool_use') {
const tr: ToolCallResult = { type: 'tool_use', name: ev.data.name, id: ev.data.id, args: ev.data.args }
activeTools.set(ev.data.id, tr)
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.tools = [...last.tools, tr]
return [...ms]
})
} else if (ev.type === 'tool_result') {
const existing = activeTools.get(ev.data.id)
if (existing) {
const updated: ToolCallResult = { ...existing, type: 'tool_result', result: ev.data.result, error: ev.data.error }
activeTools.set(ev.data.id, updated)
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
last.pendingApprovals = extractApprovals(last.tools)
}
return [...ms]
})
}
} else if (ev.type === 'text_delta') {
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text += ev.data
return [...ms]
})
} else if (ev.type === 'text') {
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text = ev.data
return [...ms]
})
} else if (ev.type === 'done') {
receivedDone = true
chat.connectionState.set('connected')
chat.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.pendingApprovals = extractApprovals(last.tools)
return [...ms]
})
startSessionPolling(sessionId)
} else if (ev.type === 'error') {
chat.error.set(ev.data)
}
},
(err: string) => {
if (err === 'AbortError' || err.includes('aborted')) {
chat.streaming.set(false)
return
}
chat.error.set(err)
if (!receivedDone) {
chat.connectionState.set('disconnected')
startSessionPolling(sessionId)
addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
} else {
chat.streaming.set(false)
}
},
() => {
chat.streaming.set(false)
if (activeControllers.get(sessionId) === controller) activeControllers.delete(sessionId)
loadSessions()
}
)
activeControllers.set(sessionId, controller)
}
export function cancelSessionStream(sessionId: string) {
const controller = activeControllers.get(sessionId)
if (!controller) return
controller.abort()
activeControllers.delete(sessionId)
chatFor(sessionId).streaming.set(false)
}
// ─── new-task launcher (desktop center input / Tasks app) ───────────────────
//
// Starting a brand-new task has no session id to hang a window off of until
// the stream's own 'session' event assigns one (see the 'session' branch in
// sendMessage above) — the desktop launcher needs to open that task's window
// the moment an id exists, not before. startTask begins the stream
// immediately, buffers any events that arrive before 'session' (defensive:
// in practice 'session' always arrives first), then seeds that session's own
// chatFor() bundle exactly like sendSessionMessage does and hands the id back
// via onSession so the caller can open its window. From that point on the
// window behaves exactly like any other task window.
export function startTask(text: string, onSession: (sessionId: string) => void): void {
const userMsg: ChatMessage = { id: mid(), role: 'user', text, tools: [], pendingApprovals: [] }
const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [], pendingApprovals: [] }
const activeTools: Map<string, ToolCallResult> = new Map()
let receivedDone = false
let sessionId: string | null = null
let chat: SessionChatState | null = null
const buffered: ChatEvent[] = []
function apply(ev: ChatEvent) {
const c = chat
if (!c || !sessionId) return
if (ev.type === 'tool_use') {
const tr: ToolCallResult = { type: 'tool_use', name: ev.data.name, id: ev.data.id, args: ev.data.args }
activeTools.set(ev.data.id, tr)
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.tools = [...last.tools, tr]
return [...ms]
})
} else if (ev.type === 'tool_result') {
const existing = activeTools.get(ev.data.id)
if (existing) {
const updated: ToolCallResult = { ...existing, type: 'tool_result', result: ev.data.result, error: ev.data.error }
activeTools.set(ev.data.id, updated)
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.tools = last.tools.map((t) => (t.id === ev.data.id ? updated : t))
last.pendingApprovals = extractApprovals(last.tools)
}
return [...ms]
})
}
} else if (ev.type === 'text_delta') {
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text += ev.data
return [...ms]
})
} else if (ev.type === 'text') {
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.text = ev.data
return [...ms]
})
} else if (ev.type === 'done') {
receivedDone = true
c.connectionState.set('connected')
c.messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') last.pendingApprovals = extractApprovals(last.tools)
return [...ms]
})
startSessionPolling(sessionId)
} else if (ev.type === 'error') {
c.error.set(ev.data)
}
}
const controller = streamChat(
text,
null,
(ev: ChatEvent) => {
if (ev.type === 'session') {
sessionId = ev.data
activeControllers.set(sessionId, controller)
chat = chatFor(sessionId)
chat.streaming.set(true)
chat.messages.update((ms) => [...ms, userMsg, assistantMsg])
onSession(sessionId)
for (const b of buffered.splice(0)) apply(b)
return
}
if (!chat) {
buffered.push(ev)
return
}
apply(ev)
},
(err: string) => {
if (!chat) return // never got a session id — nothing to show the error in
if (err === 'AbortError' || err.includes('aborted')) {
chat.streaming.set(false)
return
}
chat.error.set(err)
if (!receivedDone && sessionId) {
chat.connectionState.set('disconnected')
startSessionPolling(sessionId)
addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
} else {
chat.streaming.set(false)
}
},
() => {
if (chat) chat.streaming.set(false)
if (sessionId && activeControllers.get(sessionId) === controller) activeControllers.delete(sessionId)
loadSessions()
}
)
}

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// icons.ts only needs APPS for its default-layout ids — stub it out rather
// than pull in the real registry's full page-component graph (see
// apps.test.ts for why that graph is expensive/broken under vitest).
vi.mock('$lib/apps', () => ({
APPS: [{ id: 'tasks' }, { id: 'kb' }, { id: 'ops' }, { id: 'signals' }, { id: 'knowledge' }, { id: 'learning' }]
}))
import { GRID, iconPositions, placeIcon, iconPixelPos, maxCols, getIconPositions, resetIconLayout } from './icons'
// placeIcon mutates the shared module-level store, so each test starts from
// a known, empty layout rather than whatever the previous test (or apps.ts's
// registry-derived defaults) left behind.
beforeEach(() => {
iconPositions.set({})
localStorage.clear()
})
describe('iconPixelPos', () => {
it('converts a grid cell to pixel coordinates using GRID constants', () => {
expect(iconPixelPos({ col: 0, row: 0 })).toEqual({ x: GRID.padding, y: GRID.padding })
expect(iconPixelPos({ col: 1, row: 2 })).toEqual({
x: GRID.padding + (GRID.cell + GRID.gap),
y: GRID.padding + 2 * (GRID.cell + GRID.gap)
})
})
})
describe('maxCols', () => {
it('computes how many columns fit in a viewport width', () => {
const cellSpan = GRID.cell + GRID.gap
expect(maxCols(GRID.padding + cellSpan * 3)).toBe(3)
})
it('never returns less than 1, even for a tiny viewport', () => {
expect(maxCols(0)).toBe(1)
expect(maxCols(GRID.padding)).toBe(1)
})
})
describe('placeIcon', () => {
it('places an icon at the requested cell when it is free', () => {
placeIcon('tasks', 3, 4)
expect(getIconPositions().tasks).toEqual({ col: 3, row: 4 })
})
it('clamps negative coordinates to 0 for an otherwise-free cell', () => {
placeIcon('tasks', -5, -2)
expect(getIconPositions().tasks).toEqual({ col: 0, row: 0 })
})
it('nudges to the nearest free cell when the target is occupied', () => {
iconPositions.set({ kb: { col: 2, row: 2 } })
placeIcon('tasks', 2, 2)
const pos = getIconPositions().tasks
// Must not land on top of kb, and must be one of the 8 immediate
// neighbors (radius-1 ring) since all of them are free.
expect(pos).not.toEqual({ col: 2, row: 2 })
expect(Math.max(Math.abs(pos.col - 2), Math.abs(pos.row - 2))).toBe(1)
})
it('does not disturb the icon already occupying a cell when another icon is nudged past it', () => {
iconPositions.set({ kb: { col: 2, row: 2 } })
placeIcon('tasks', 2, 2)
expect(getIconPositions().kb).toEqual({ col: 2, row: 2 })
})
it('moving an icon back onto its own current cell is a no-op collision (never nudges against itself)', () => {
iconPositions.set({ tasks: { col: 5, row: 5 } })
placeIcon('tasks', 5, 5)
expect(getIconPositions().tasks).toEqual({ col: 5, row: 5 })
})
it('persists the updated layout to localStorage', () => {
placeIcon('tasks', 1, 1)
const stored = JSON.parse(localStorage.getItem('oikos-desktop-icons') ?? '{}')
expect(stored.tasks).toEqual({ col: 1, row: 1 })
})
})
describe('resetIconLayout', () => {
it('restores the classic left-edge column in registry order', () => {
iconPositions.set({ tasks: { col: 4, row: 7 }, kb: { col: 1, row: 1 } })
resetIconLayout()
expect(getIconPositions()).toEqual({
tasks: { col: 0, row: 0 },
kb: { col: 0, row: 1 },
ops: { col: 0, row: 2 },
signals: { col: 0, row: 3 },
knowledge: { col: 0, row: 4 },
learning: { col: 0, row: 5 }
})
})
})

113
web/src/lib/stores/icons.ts Normal file
View File

@@ -0,0 +1,113 @@
// Desktop icon positions — a simple column/row grid, persisted to
// localStorage so icons stay where the operator put them across reloads.
// Deliberately NOT wmkit: wmkit manages floating windows (pixel bounds,
// z-order, stage), icons are a flat, non-overlapping grid with a much
// simpler drag model (snap-to-cell, no resize/minimize/stack). Reinventing
// that inside wmkit would mean fighting its window-shaped abstractions for
// no benefit.
import { writable, get } from 'svelte/store'
import { APPS } from '$lib/apps'
export interface IconPos {
col: number
row: number
}
export const GRID = { cell: 96, gap: 12, padding: 16 }
const STORAGE_KEY = 'oikos-desktop-icons'
function defaultPositions(): Record<string, IconPos> {
// Classic OS default: one left-edge column, registry order.
const out: Record<string, IconPos> = {}
APPS.forEach((app, i) => {
out[app.id] = { col: 0, row: i }
})
return out
}
function load(): Record<string, IconPos> {
if (typeof localStorage === 'undefined') return defaultPositions()
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return defaultPositions()
const parsed = JSON.parse(raw) as Record<string, IconPos>
const out = defaultPositions()
// Merge over the defaults so a newly-registered app (not in the saved
// blob yet) still gets a sane starting position instead of being absent
// from the grid entirely.
for (const [id, pos] of Object.entries(parsed)) {
if (appIds.has(id)) out[id] = pos
}
return out
} catch {
return defaultPositions()
}
}
const appIds = new Set(APPS.map((a) => a.id))
export const iconPositions = writable<Record<string, IconPos>>(load())
function persist(positions: Record<string, IconPos>): void {
if (typeof localStorage === 'undefined') return
localStorage.setItem(STORAGE_KEY, JSON.stringify(positions))
}
iconPositions.subscribe((positions) => persist(positions))
function occupied(positions: Record<string, IconPos>, col: number, row: number, exceptId: string): boolean {
return Object.entries(positions).some(([id, p]) => id !== exceptId && p.col === col && p.row === row)
}
// Finds the nearest free cell to (col, row) via an expanding ring search,
// so dropping an icon onto an occupied cell nudges it to the closest open
// spot instead of silently overlapping or refusing the drop.
function nearestFreeCell(
positions: Record<string, IconPos>,
col: number,
row: number,
exceptId: string
): IconPos {
if (!occupied(positions, col, row, exceptId)) return { col: Math.max(0, col), row: Math.max(0, row) }
for (let radius = 1; radius < 64; radius++) {
for (let dc = -radius; dc <= radius; dc++) {
for (let dr = -radius; dr <= radius; dr++) {
if (Math.max(Math.abs(dc), Math.abs(dr)) !== radius) continue
const c = col + dc
const r = row + dr
if (c < 0 || r < 0) continue
if (!occupied(positions, c, r, exceptId)) return { col: c, row: r }
}
}
}
return { col: Math.max(0, col), row: Math.max(0, row) }
}
export function placeIcon(appId: string, col: number, row: number): void {
iconPositions.update((positions) => {
const target = nearestFreeCell(positions, col, row, appId)
return { ...positions, [appId]: target }
})
}
export function iconPixelPos(pos: IconPos): { x: number; y: number } {
return {
x: GRID.padding + pos.col * (GRID.cell + GRID.gap),
y: GRID.padding + pos.row * (GRID.cell + GRID.gap)
}
}
export function maxCols(viewportWidth: number): number {
return Math.max(1, Math.floor((viewportWidth - GRID.padding) / (GRID.cell + GRID.gap)))
}
export function getIconPositions(): Record<string, IconPos> {
return get(iconPositions)
}
// Bails a messy manual layout back to the classic left-edge column,
// registry order — the desktop's right-click menu's "Reset icon layout".
export function resetIconLayout(): void {
iconPositions.set(defaultPositions())
}

View File

@@ -1,14 +1,83 @@
// One global wmkit window manager for the whole app (mounted once by
// EntityDesktop.svelte in App.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.
// 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 { createManager, createDesktop, wmStore } from '@surdeddd/wmkit/svelte'
import { persist } from '@surdeddd/wmkit/persist'
import { appById, appWindowId } from '$lib/apps'
import { sessions } from '$lib/stores/chat'
import { heading } from '$lib/tasks'
export const wm = createManager({ defaultSize: { width: 480, height: 560 } })
export const dk = createDesktop(wm)
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)
// 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.
export function openAppWindow(appId: string): void {
const app = appById.get(appId)
if (!app) return
const id = appWindowId(appId)
if (wm.get(id)) {
wm.restore(id)
wm.focus(id)
return
}
wm.open({
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
@@ -22,3 +91,36 @@ export function openEntityWindow(slug: string | null): void {
}
wm.open({ 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; TaskLauncher closes it itself (via its onStarted
// callback, wired up in WindowLayer.svelte) once the task's session window
// takes over.
export const NEW_TASK_WINDOW_ID = 'new-task'
export function openNewTaskWindow(): void {
if (wm.get(NEW_TASK_WINDOW_ID)) {
wm.restore(NEW_TASK_WINDOW_ID)
wm.focus(NEW_TASK_WINDOW_ID)
return
}
wm.open({ id: NEW_TASK_WINDOW_ID, title: 'New task', width: 480, height: 340, minWidth: 360, minHeight: 280 })
}
// 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({ id, title, width: 900, height: 640 })
}

View File

@@ -1,7 +1,7 @@
import { writable, derived, get } from 'svelte/store'
import { writable, derived, get, type Writable, type Readable } from 'svelte/store'
import { liveEvents, subscribeEvents } from './events'
import { currentSession, sessions, loadSessions } from './chat'
import { fetchPlan, fetchQuestions, type PlanStep, type SessionQuestion } from '$lib/api'
import { fetchPlan, fetchQuestions, type PlanStep, type SessionQuestion, type Session } from '$lib/api'
import type {
PlanProposedData,
PlanStepEventData,
@@ -21,16 +21,11 @@ import type {
// keeps updating across a tab reload: hydrate() re-fetches REST state, then
// live events carry deltas from there.
export const planSteps = writable<PlanStep[]>([])
export const questions = writable<SessionQuestion[]>([])
export const openQuestion = derived(questions, (qs) => qs.find((q) => q.status === 'open') ?? null)
export interface TouchedEntity {
slug: string
tool: string
ts: number
}
export const touched = writable<TouchedEntity[]>([])
const TOUCHED_MAX = 12
const TOUCHED_PULSE_MS = 6000
@@ -40,9 +35,35 @@ export interface HealthDiff {
to: string
ts: number
}
export const healthDiffs = writable<HealthDiff[]>([])
const HEALTH_DIFF_MS = 8000
export interface WorkspaceState {
planSteps: Writable<PlanStep[]>
questions: Writable<SessionQuestion[]>
openQuestion: Readable<SessionQuestion | null>
touched: Writable<TouchedEntity[]>
healthDiffs: Writable<HealthDiff[]>
}
function createWorkspaceState(): WorkspaceState {
const questions = writable<SessionQuestion[]>([])
return {
planSteps: writable<PlanStep[]>([]),
questions,
openQuestion: derived(questions, (qs) => qs.find((q) => q.status === 'open') ?? null),
touched: writable<TouchedEntity[]>([]),
healthDiffs: writable<HealthDiff[]>([])
}
}
// ─── global "current session" workspace — used by the main Chat page's rail ─
const globalWorkspace = createWorkspaceState()
export const planSteps = globalWorkspace.planSteps
export const questions = globalWorkspace.questions
export const openQuestion = globalWorkspace.openQuestion
export const touched = globalWorkspace.touched
export const healthDiffs = globalWorkspace.healthDiffs
// The task's own fields (goal/status/outcome/summary) live on the session row.
// Rather than a dedicated endpoint, derive from the sessions list (already
// fetched for the task board) and keep it fresh here on task-lifecycle events.
@@ -50,33 +71,21 @@ export const currentTask = derived([sessions, currentSession], ([$sessions, $id]
$sessions.find((s) => s.id === $id) ?? null
)
// Events that can change agent_sessions.status/goal/outcome — see applyEvent.
// Events that can change agent_sessions.status/goal/outcome — see applyEventTo.
const STATUS_AFFECTING = new Set([
'goal.set', 'task.status', 'plan.proposed', 'question.raised', 'question.answered'
])
let hydratedFor: string | null = null
let unsubStream: (() => void) | null = null
let unsubLive: (() => void) | null = null
let refreshTimer: ReturnType<typeof setTimeout> | null = null
let lastSeenId = 0
async function hydrate(sessionId: string) {
hydratedFor = sessionId
planSteps.set([])
questions.set([])
touched.set([])
healthDiffs.set([])
const [steps, qs] = await Promise.all([fetchPlan(sessionId), fetchQuestions(sessionId)])
if (get(currentSession) !== sessionId) return // switched away while loading
planSteps.set(steps)
questions.set(qs)
function scheduleSessionsRefresh() {
if (refreshTimer) clearTimeout(refreshTimer)
refreshTimer = setTimeout(() => loadSessions(), 300)
}
function applyPlanStepEvent(sessionId: string, type: string, data: PlanStepEventData) {
function applyPlanStepEventTo(ws: WorkspaceState, data: PlanStepEventData) {
const stepID = data?.step_id
const seq = data?.seq
planSteps.update((steps) => {
ws.planSteps.update((steps) => {
const i = steps.findIndex((s) => (stepID && s.id === stepID) || (seq != null && s.seq === seq))
if (i === -1) return steps
const next = [...steps]
@@ -85,9 +94,11 @@ function applyPlanStepEvent(sessionId: string, type: string, data: PlanStepEvent
})
}
function applyEvent(ev: { type: string; correlation_id?: string | null; data?: unknown }) {
const sid = get(currentSession)
if (!sid || ev.correlation_id !== sid) return
// Applies a live event to `ws` if it belongs to session `sid` — shared by the
// global "current session" watcher and every per-session floating-window
// watcher, each passing its own target state and session id.
function applyEventTo(ws: WorkspaceState, sid: string, ev: { type: string; correlation_id?: string | null; data?: unknown }) {
if (ev.correlation_id !== sid) return
const data = (ev.data ?? {}) as Record<string, unknown>
// Task fields (status/goal/outcome) live on the session row — refetch the
@@ -100,11 +111,9 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
// server-side, with no client-streaming 'done' event to piggyback a refresh
// on (found live: answering a question via the panel left the header stuck
// on "Needs your input" after the agent had already resumed). Debounced
// since several of these can land in one burst.
if (STATUS_AFFECTING.has(ev.type)) {
if (refreshTimer) clearTimeout(refreshTimer)
refreshTimer = setTimeout(() => loadSessions(), 300)
}
// since several of these can land in one burst, and shared across
// sessions since it just refreshes the one global session list.
if (STATUS_AFFECTING.has(ev.type)) scheduleSessionsRefresh()
switch (ev.type) {
case 'plan.proposed': {
@@ -114,17 +123,17 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
id: s.id, seq: s.seq, title: s.title, detail: s.detail ?? '',
status: 'pending' as const, target_slug: s.target_slug || undefined
}))
planSteps.update((existing) => (d.appended ? [...existing, ...incoming] : incoming))
ws.planSteps.update((existing) => (d.appended ? [...existing, ...incoming] : incoming))
}
break
}
case 'plan.step.started':
case 'plan.step.finished':
applyPlanStepEvent(sid, ev.type, data as unknown as PlanStepEventData)
applyPlanStepEventTo(ws, data as unknown as PlanStepEventData)
break
case 'question.raised': {
const d = data as unknown as QuestionRaisedData
questions.update((qs) => [
ws.questions.update((qs) => [
{
id: d.question_id, prompt: d.prompt ?? '',
context: { why: d.why, options: d.options, entities: d.entities },
@@ -136,7 +145,7 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
}
case 'question.answered': {
const d = data as unknown as QuestionAnsweredData
questions.update((qs) =>
ws.questions.update((qs) =>
qs.map((q) => (q.id === d.question_id ? { ...q, status: 'answered', answer: d.answer } : q))
)
break
@@ -145,7 +154,7 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
const d = data as unknown as EntityTouchedData
if (d.slug) {
const now = Date.now()
touched.update((t) => [{ slug: d.slug, tool: d.tool ?? '', ts: now }, ...t].slice(0, TOUCHED_MAX))
ws.touched.update((t) => [{ slug: d.slug, tool: d.tool ?? '', ts: now }, ...t].slice(0, TOUCHED_MAX))
}
break
}
@@ -157,13 +166,30 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
// health.changed is task-agnostic (fleet-wide), so it's matched separately:
// show the diff whenever the changed entity is one this task has touched, not
// by correlation_id (health events don't carry one).
function applyHealthChanged(ev: { type: string; data?: unknown }) {
function applyHealthChangedTo(ws: WorkspaceState, ev: { type: string; data?: unknown }) {
if (ev.type !== 'health.changed') return
const data = (ev.data ?? {}) as HealthChangedData
if (!data.slug) return
const isRelevant = get(touched).some((t) => t.slug === data.slug)
const isRelevant = get(ws.touched).some((t) => t.slug === data.slug)
if (!isRelevant) return
healthDiffs.update((d) => [{ slug: data.slug, from: data.from ?? '', to: data.to ?? '', ts: Date.now() }, ...d].slice(0, 8))
ws.healthDiffs.update((d) => [{ slug: data.slug, from: data.from ?? '', to: data.to ?? '', ts: Date.now() }, ...d].slice(0, 8))
}
let hydratedFor: string | null = null
let unsubStream: (() => void) | null = null
let unsubLive: (() => void) | null = null
let lastSeenId = 0
async function hydrate(sessionId: string) {
hydratedFor = sessionId
globalWorkspace.planSteps.set([])
globalWorkspace.questions.set([])
globalWorkspace.touched.set([])
globalWorkspace.healthDiffs.set([])
const [steps, qs] = await Promise.all([fetchPlan(sessionId), fetchQuestions(sessionId)])
if (get(currentSession) !== sessionId) return // switched away while loading
globalWorkspace.planSteps.set(steps)
globalWorkspace.questions.set(qs)
}
// startWorkspace opens the global event subscription and begins tracking the
@@ -176,10 +202,10 @@ export function startWorkspace(): () => void {
if (sid && sid !== hydratedFor) hydrate(sid)
if (!sid) {
hydratedFor = null
planSteps.set([])
questions.set([])
touched.set([])
healthDiffs.set([])
globalWorkspace.planSteps.set([])
globalWorkspace.questions.set([])
globalWorkspace.touched.set([])
globalWorkspace.healthDiffs.set([])
}
})
@@ -191,11 +217,13 @@ export function startWorkspace(): () => void {
}
const fresh = evs.filter((e) => e.id > lastSeenId)
lastSeenId = maxId
const sid = get(currentSession)
if (!sid) return
// Oldest-first application so ordering (e.g. plan.step.started before
// .finished) is preserved.
for (const e of fresh.slice().reverse()) {
applyEvent(e)
applyHealthChanged(e)
applyEventTo(globalWorkspace, sid, e)
applyHealthChangedTo(globalWorkspace, e)
}
})
@@ -206,9 +234,69 @@ export function startWorkspace(): () => void {
}
}
// Sweep expired pulses/diffs on an interval so old touches stop glowing.
// ─── per-session workspace, for floating task windows ───────────────────────
//
// Same shape as the global workspace above, but keyed by session id instead
// of "whatever's on screen" — mirrors chat.ts's chatFor(). A window's
// TaskContextPanel calls startSessionWorkspace(sessionId) instead of
// startWorkspace(), and reads workspaceFor(sessionId)'s stores instead of the
// global ones, so several sessions' panels can be open and live at once.
const workspaces = new Map<string, WorkspaceState>()
export function workspaceFor(sessionId: string): WorkspaceState {
let w = workspaces.get(sessionId)
if (!w) {
w = createWorkspaceState()
workspaces.set(sessionId, w)
}
return w
}
export function taskFor(sessionId: string): Readable<Session | null> {
return derived(sessions, ($sessions) => $sessions.find((s) => s.id === sessionId) ?? null)
}
async function hydrateSession(ws: WorkspaceState, sessionId: string) {
const [steps, qs] = await Promise.all([fetchPlan(sessionId), fetchQuestions(sessionId)])
ws.planSteps.set(steps)
ws.questions.set(qs)
}
export function startSessionWorkspace(sessionId: string): () => void {
const ws = workspaceFor(sessionId)
const unsub = subscribeEvents()
hydrateSession(ws, sessionId)
// Own "seen" watermark rather than the global lastSeenId — several
// windows, each watching a different session, can be reading off the same
// liveEvents feed at once.
let lastSeen = 0
const unsubLive = liveEvents.subscribe((evs) => {
if (evs.length === 0) return
const maxId = evs[0].id
if (maxId <= lastSeen) return
const fresh = evs.filter((e) => e.id > lastSeen)
lastSeen = maxId
for (const e of fresh.slice().reverse()) {
applyEventTo(ws, sessionId, e)
applyHealthChangedTo(ws, e)
}
})
return () => {
unsubLive()
unsub()
}
}
// Sweep expired pulses/diffs on an interval so old touches stop glowing —
// across the global workspace and every per-session one currently in use.
setInterval(() => {
const now = Date.now()
touched.update((t) => t.filter((e) => now - e.ts < TOUCHED_PULSE_MS))
healthDiffs.update((d) => d.filter((e) => now - e.ts < HEALTH_DIFF_MS))
const sweep = (ws: WorkspaceState) => {
ws.touched.update((t) => t.filter((e) => now - e.ts < TOUCHED_PULSE_MS))
ws.healthDiffs.update((d) => d.filter((e) => now - e.ts < HEALTH_DIFF_MS))
}
sweep(globalWorkspace)
for (const ws of workspaces.values()) sweep(ws)
}, 1000)

View File

@@ -1,412 +0,0 @@
<script lang="ts">
import { messages, streaming, connectionState, currentSession, sendMessage, cancelStream, reconnect, error, chatErrors, dismissError } from '$lib/stores/chat'
import { activityLog } from '$lib/stores/activity'
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
import AgentIndicator from '$lib/components/AgentIndicator.svelte'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
import SquareIcon from '@lucide/svelte/icons/square'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
let { showRail = true }: { showRail?: boolean } = $props()
let input = $state('')
let messagesEnd = $state<HTMLDivElement | null>(null)
let scrolledUp = $state(false)
let container = $state<HTMLDivElement | null>(null)
function isNearBottom(): boolean {
if (!container) return true
const { scrollTop, scrollHeight, clientHeight } = container
return scrollHeight - scrollTop - clientHeight < 80
}
function onScroll() {
scrolledUp = !isNearBottom()
}
// Auto-scroll to bottom on new messages — unless user scrolled up to read.
$effect(() => {
void $messages
if ($streaming || !scrolledUp) {
setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
}
})
// Reset scroll lock when user sends a message.
function submitFollows() {
scrolledUp = false
}
// 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)
}
function render(text: string): string {
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string)
}
function submit() {
const text = input.trim()
if (!text || $streaming) return
input = ''
scrolledUp = false
sendMessage(text)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
}
const suggestions = [
'What needs my attention right now?',
'Summarize fleet health',
'Any pending approvals or open signals?',
'What changed in the last hour?'
]
function ask(q: string) {
if ($streaming) return
sendMessage(q)
}
</script>
<div class="flex h-full min-h-0">
<div class="flex min-w-0 flex-1 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>
<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>
</div>
{/if}
{#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 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>
{/if}
</div>
{/if}
</div>
{/each}
<AgentIndicator
active={$streaming || $activityLog.some((e) => e.status === 'running')}
lastActivity={$activityLog.find((e) => e.status === 'running') ?? null}
error={$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={reconnect}>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={() => dismissError(err.id)}>{err.action}</Button>
{/if}
<button class="ml-1 text-muted-foreground hover:text-foreground" onclick={() => dismissError(err.id)} aria-label="Dismiss">×</button>
</div>
</div>
{/each}
<div class="border-t bg-card/50 p-3 input-ornament relative">
<form
class="mx-auto flex max-w-3xl items-end gap-2"
onsubmit={(e) => {
e.preventDefault()
submit()
}}
>
<Textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="Ask Nomos anything…"
rows={2}
class="max-h-40 min-h-0 resize-none"
disabled={$streaming}
/>
{#if $streaming}
<Button type="button" size="icon" variant="destructive" onclick={cancelStream} aria-label="Stop">
<SquareIcon />
</Button>
{:else}
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Send">
<ArrowUpIcon />
</Button>
{/if}
</form>
</div>
</div>
{#if showRail}
<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 task panel"
>
<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="flex min-w-0 flex-1 flex-col">
<TaskContextPanel />
</div>
</div>
{/if}
</div>
<style>
/* ── Art Nouveau chat styling ── */
/* Assistant message wrapper */
.assistant-msg {
position: relative;
}
/* User message — soft terracotta bubble, gentle lift */
.user-msg {
box-shadow: 0 1px 8px -4px var(--primary);
}
/* 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-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);
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;
padding: 0.75rem 0.875rem;
overflow-x: auto;
margin: 0 0 0.5rem;
position: relative;
}
.prose-chat :global(pre)::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(to right, transparent, var(--primary), transparent);
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;
margin: 1.15rem 0 0.4rem;
font-size: 1.03em;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.prose-chat :global(> h1:first-child),
.prose-chat :global(> h2:first-child),
.prose-chat :global(> h3:first-child) {
margin-top: 0;
}
.prose-chat :global(h1)::after,
.prose-chat :global(h2)::after,
.prose-chat :global(h3)::after {
content: '';
display: block;
width: 2.5rem;
height: 2px;
margin-top: 4px;
border-radius: 1px;
background: linear-gradient(to right, var(--primary), transparent);
opacity: 0.55;
}
.prose-chat :global(table) {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.prose-chat :global(th) {
background: var(--muted);
font-weight: 600;
}
.prose-chat :global(th),
.prose-chat :global(td) {
border: 1px solid var(--border);
padding: 0.3rem 0.6rem;
text-align: left;
}
.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;
}
.prose-chat :global(blockquote)::before {
content: '“';
position: absolute;
left: -0.15rem;
top: -0.35rem;
font-size: 1.5rem;
color: var(--primary);
opacity: 0.6;
font-style: normal;
line-height: 1;
}
.prose-chat :global(hr) {
border: none;
height: 1px;
margin: 0.75rem 0;
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
terracotta accent stay meaningful (code, headings, links). */
.prose-chat :global(strong) {
color: var(--foreground);
font-weight: 600;
}
.prose-chat :global(a) {
color: var(--primary);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
/* Input area ornament */
.input-ornament::before {
content: '';
position: absolute;
top: 0;
left: 2rem;
right: 2rem;
height: 1px;
background: linear-gradient(to right, transparent, var(--primary), transparent);
opacity: 0.3;
}
</style>

View File

@@ -1,8 +0,0 @@
<script lang="ts">
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
import { openEntityWindow } from '$lib/stores/windows'
let { slug }: { slug: string } = $props()
</script>
<EntityDetailContent {slug} onSelectEntity={openEntityWindow} />

View File

@@ -1,15 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchEntities, fetchOntology, fetchGraph, type Entity, type EntityType, type Ontology } from '$lib/api'
import { fetchAllEntities, fetchOntology, fetchGraph, type Entity, type EntityType, type Ontology } from '$lib/api'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import EntityTable from '$lib/components/EntityTable.svelte'
import EntityGraph, { type GraphInfo } from '$lib/components/EntityGraph.svelte'
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte'
import { categories, filtersForCategory, type Category } from '$lib/categories'
import { typeToCategory, type Category } from '$lib/categories'
import { openEntityWindow, wmState } from '$lib/stores/windows'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import * as Select from '$lib/components/ui/select'
import { Switch } from '$lib/components/ui/switch'
import { Label } from '$lib/components/ui/label'
import NetworkIcon from '@lucide/svelte/icons/share-2'
@@ -23,10 +22,13 @@
return localStorage.getItem('oikos-kb-view') === 'table' ? 'table' : 'graph'
}
let category = $state<Category>('fleet')
let view = $state<View>(loadView())
// Shared between table (filters rows) and graph (highlights/searches
// nodes) — one search box instead of two differently-labeled ones, since
// both views are asking the same underlying question ("show me X").
let search = $state('')
// Tracks only the most recently opened entity, for row/node highlight —
// actual detail viewing now happens in floating windows (EntityDesktop),
// actual detail viewing now happens in floating windows (WindowLayer, mounted inside Desktop.svelte),
// which can have several entities open at once.
let lastOpened = $state<string | null>(null)
@@ -40,41 +42,41 @@
openEntityWindow(slug)
}
// Drop the highlight once its window is closed (from EntityDesktop, not
// Drop the highlight once its window is closed (from WindowLayer, not
// necessarily from here) rather than leaving a row/node marked "open" when
// it isn't anymore.
$effect(() => {
if (lastOpened && !$wmState.windows[lastOpened]) lastOpened = null
})
// ─── table data: fetched here (not inside EntityTable) so the search/type
// ─── entities: fetched here (not inside EntityTable) so the search/type
// toolbar lives in the shared page toolbar instead of the resizable browse
// pane, where its width is at the mercy of the divider and it would
// truncate. This also keeps the browse pane header-free, so it and the
// detail pane both start flush under the toolbar and end up the same height.
let tableEntities = $state<Entity[]>([])
let tableLoading = $state(true)
let query = $state('')
let typeFilter = $state('all')
// truncate. Both views now share the same full entity set — there's no
// more per-category server-side scoping, only the client-side type
// multiselect (activeTypes) below, which both the table (row visibility)
// and the graph (node visibility) read from.
let allEntities = $state<Entity[]>([])
let entitiesLoading = $state(true)
let showInactive = $state(false)
// child entity slug -> parent entity slug, derived from the ontology graph
// (see loadFleetGrouping). Only populated for the fleet category — feeds
// EntityTable's treegrid grouping, nesting e.g.
// cluster -> host -> lxc -> service, or storage-pool -> volume -> dataset.
// (see loadGrouping). Feeds EntityTable's treegrid grouping, nesting e.g.
// host -> lxc -> service, or storage-pool -> volume -> dataset — computed
// over the whole entity set so the hierarchy doesn't reshuffle as the type
// filter is toggled (EntityTable falls back a filtered-out parent's
// children to top-level rather than dropping them).
let childToParent = $state<Map<string, string> | null>(null)
let ontologyCache: Promise<Ontology> | null = null
function isOrDescendsFrom(byName: Map<string, EntityType>, typeName: string, ancestor: string): boolean {
if (typeName === ancestor) return true
let t = byName.get(typeName)
for (let depth = 0; t?.parent_type && depth < 10; depth++) {
if (t.parent_type === ancestor) return true
t = byName.get(t.parent_type)
}
return false
let ontologyPromise: Promise<Ontology> | null = null
function getOntology(): Promise<Ontology> {
ontologyPromise ??= fetchOntology()
return ontologyPromise
}
// type -> browsing category (see categories.ts), used only to seed the
// type multiselect's default selection ("fleet") — not to scope any fetch.
let typeCategory = $state<Map<string, Category | undefined>>(new Map())
// Distance from the ontology's abstract root ("entity") down to typeName —
// 0 for entity itself, 1 for its direct subtypes, etc. Used as a
// specificity score: a relationship whose parent-side type is a generic
@@ -97,9 +99,9 @@
// parent (e.g. many hosts are located-at one site). many-to-many
// relationships (mounts, stores-on, backs-up-to, ...) have no single
// parent, so they're excluded from tree nesting. A candidate parent that
// isn't actually part of the fleet set being browsed (e.g. `cluster`,
// filtered out below) is dropped rather than kept as a dangling pointer —
// that's also what lets `located-at` surface as a host's parent instead of
// isn't actually part of the set being browsed (e.g. `cluster`, filtered
// out below) is dropped rather than kept as a dangling pointer — that's
// also what lets `located-at` surface as a host's parent instead of
// `member-of` without any special-cased priority: with cluster absent,
// member-of simply has nothing valid to point at. An entity can still be
// the child end of several different *remaining* relationship types at
@@ -107,81 +109,82 @@
// repo) — only one can win as its tree parent, so ties go to the more
// specific relationship (see typeDepth) rather than whichever was fetched
// last.
async function loadFleetGrouping(fleetEntities: Entity[]): Promise<Map<string, string>> {
ontologyCache ??= fetchOntology()
const { entityTypes, relationshipTypes } = await ontologyCache
async function loadGrouping(entities: Entity[]): Promise<Map<string, string>> {
const { entityTypes, relationshipTypes } = await getOntology()
const byName = new Map(entityTypes.map((t) => [t.name, t]))
const hierRels = relationshipTypes.filter((rt) => rt.cardinality !== 'many-to-many')
const relTypeNames = hierRels.map((rt) => rt.name)
const cardinalityByType = new Map(hierRels.map((rt) => [rt.name, rt.cardinality]))
const specificityByType = new Map(hierRels.map((rt) => [rt.name, typeDepth(byName, rt.source_type)]))
const fleetSlugs = new Set(fleetEntities.map((e) => e.slug))
const slugs = new Set(entities.map((e) => e.slug))
// blast_radius only walks source -> target, so an entity only surfaces a
// relationship if it can be that relationship's source.
const roots = fleetEntities.filter((e) =>
hierRels.some((rt) => isOrDescendsFrom(byName, e.type, rt.source_type))
)
const pairs = await Promise.all(
roots.map(async (root) => {
const g = await fetchGraph({ root: root.slug, depth: 1, relType: relTypeNames })
return (g?.edges ?? [])
.filter((edge) => edge.source === root.slug && cardinalityByType.has(edge.type))
.map((edge) => {
const [child, parent] =
cardinalityByType.get(edge.type) === 'many-to-one'
? [edge.source, edge.target] // root is the child; target is the "one" (parent)
: [edge.target, edge.source] // root is the "one" (parent); target is the child
return { child, parent, weight: specificityByType.get(edge.type) ?? 0 }
})
.filter(({ parent }) => fleetSlugs.has(parent))
// One whole-graph fetch instead of one rooted fetch per candidate parent
// — now that grouping runs over the entire entity set rather than a
// ~50-entity category, firing a request per entity blew past the
// browser's concurrent-connection limit (ERR_INSUFFICIENT_RESOURCES).
const g = await fetchGraph({ relType: relTypeNames })
const pairs = (g?.edges ?? [])
.filter((edge) => cardinalityByType.has(edge.type) && slugs.has(edge.source) && slugs.has(edge.target))
.map((edge) => {
const [child, parent] =
cardinalityByType.get(edge.type) === 'many-to-one'
? [edge.source, edge.target] // source is the child; target is the "one" (parent)
: [edge.target, edge.source] // source is the "one" (parent); target is the child
return { child, parent, weight: specificityByType.get(edge.type) ?? 0 }
})
)
const best = new Map<string, { parent: string; weight: number }>()
for (const { child, parent, weight } of pairs.flat()) {
for (const { child, parent, weight } of pairs) {
const current = best.get(child)
if (!current || weight > current.weight) best.set(child, { parent, weight })
}
return new Map([...best].map(([child, { parent }]) => [child, parent]))
}
async function loadTable() {
tableLoading = true
const filterSets = filtersForCategory(category)
const results = await Promise.all(filterSets.map((f) => fetchEntities(f)))
// cluster entities aren't shown in Fleet browsing — with them absent, a
// host's `member-of` edge has no valid parent to point at, so
// `located-at` (site) is the only remaining candidate and wins the
// tree-parent tie-break without a hardcoded relationship priority (see
// loadFleetGrouping).
const fetched = results.flat().filter((e) => e.type !== 'cluster')
childToParent = category === 'fleet' ? await loadFleetGrouping(fetched) : null
tableEntities = fetched
tableLoading = false
async function loadEntities() {
entitiesLoading = true
// cluster entities are dropped so a host's `member-of` edge has no valid
// parent to point at, leaving `located-at` (site) as the only remaining
// tree-parent candidate (see loadGrouping).
const fetched = (await fetchAllEntities()).filter((e) => e.type !== 'cluster')
childToParent = await loadGrouping(fetched)
allEntities = fetched
entitiesLoading = false
}
onMount(() => {
loadEntities()
getOntology().then((o) => {
typeCategory = new Map(o.entityTypes.map((t) => [t.name, typeToCategory(t.name, t.domain)]))
})
const unsubscribe = subscribeEvents()
return unsubscribe
})
$effect(() => {
if (view !== 'table') return
category
loadTable()
})
$effect(() => {
const ev = $liveEvents[0]
if (view !== 'table' || !ev || !ev.type.startsWith('entity.')) return
loadTable()
if (!ev || !ev.type.startsWith('entity.')) return
loadEntities()
})
const allTypes = $derived(Array.from(new Set(allEntities.map((e) => e.type))).sort())
// Shared show/hide-by-type filter — governs both the table's row
// visibility and the graph's node visibility. Seeded once (not
// re-derived) to "fleet" types as soon as both the entity set and the
// ontology's type->category map are loaded, so it doesn't clobber the
// user's own toggles on a later reload.
let activeTypes = $state<Set<string>>(new Set())
let typesSeeded = false
$effect(() => {
if (typesSeeded || allTypes.length === 0 || typeCategory.size === 0) return
activeTypes = new Set(allTypes.filter((t) => typeCategory.get(t) === 'fleet'))
typesSeeded = true
})
const tableTypes = $derived(Array.from(new Set(tableEntities.map((e) => e.type))).sort())
const filteredEntities = $derived.by(() => {
const q = query.trim().toLowerCase()
return tableEntities.filter((e) => {
if (typeFilter !== 'all' && e.type !== typeFilter) return false
const q = search.trim().toLowerCase()
return allEntities.filter((e) => {
if (!activeTypes.has(e.type)) return false
if (q && !e.slug.toLowerCase().includes(q) && !e.name.toLowerCase().includes(q)) return false
// entities with no tracked lifecycle state (state is null) aren't
// "destroyed or inactive" — only hide ones whose tracked state has
@@ -196,12 +199,10 @@
// width instead of being squeezed by the resizable browse pane.
let graphRoot = $state('')
let graphDepth = $state(2)
let graphSearch = $state('')
let graphReloadToken = $state(0)
let graphResetToken = $state(0)
let graphActiveNodeTypes = $state<Set<string>>(new Set())
let graphActiveRelTypes = $state<Set<string>>(new Set())
let graphInfo = $state<GraphInfo>({ allNodeTypes: [], allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
let graphInfo = $state<GraphInfo>({ allRelTypes: [], relColors: new Map(), visibleCount: 0, truncated: false, zoomPct: 100 })
function commitGraphQuery() {
graphReloadToken++
@@ -209,7 +210,7 @@
function resetGraph() {
graphRoot = ''
graphSearch = ''
search = ''
graphResetToken++
}
@@ -219,97 +220,76 @@
</script>
<div class="flex h-full flex-col gap-3 p-4">
<!-- toolbar: category perspective + view toggle -->
<div class="flex flex-wrap items-center gap-3">
<div class="inline-flex overflow-hidden rounded-md border">
{#each categories as c}
<Button
variant={category === c.id ? 'secondary' : 'ghost'}
size="sm"
class="h-8 rounded-none border-0"
onclick={() => (category = c.id)}
>
{c.label}
</Button>
{/each}
</div>
<!-- single toolbar row: search + type filter are shared by both views
(one multiselect instead of a category tab, a single-select "All
types" dropdown, and a separate graph node-type toggle), the rest is
view-specific, and the graph/table switch sits inline with the rest
instead of floating in its own row. -->
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Filter / highlight by slug or name…" bind:value={search} class="h-8 max-w-xs text-xs" />
<MultiSelectFilter label="Types" options={allTypes} bind:selected={activeTypes} />
{#if view === 'table'}
<div class="flex items-center gap-1.5">
<Switch id="show-inactive" bind:checked={showInactive} />
<Label for="show-inactive" class="text-xs font-normal text-muted-foreground">Inactive</Label>
</div>
<span class="text-xs text-muted-foreground">{filteredEntities.length} of {allEntities.length}</span>
{:else}
<Input placeholder="Root entity…" bind:value={graphRoot} class="h-8 max-w-40 text-xs" onchange={commitGraphQuery} />
<Input type="number" min="1" max="5" bind:value={graphDepth} class="h-8 w-14 text-xs" onchange={commitGraphQuery} />
<Button variant="outline" size="sm" class="h-8" onclick={resetGraph}>
<LocateFixedIcon class="mr-1 size-3.5" />
Reset
</Button>
<MultiSelectFilter label="Edges" options={graphInfo.allRelTypes} bind:selected={graphActiveRelTypes} colorFor={relColorFor} />
<span class="text-xs text-muted-foreground">
{graphInfo.visibleCount} nodes{graphInfo.truncated ? ' · truncated' : ''} · {graphInfo.zoomPct}%
</span>
{/if}
<div class="ml-auto inline-flex overflow-hidden rounded-md border">
<Button
variant={view === 'graph' ? 'secondary' : 'ghost'}
size="sm"
class="h-8 rounded-none border-0"
class="h-8 rounded-none border-0 px-2"
onclick={() => setView('graph')}
title="Graph view"
aria-label="Graph view"
>
<NetworkIcon class="mr-1 size-3.5" /> Graph
<NetworkIcon class="size-3.5" />
</Button>
<Button
variant={view === 'table' ? 'secondary' : 'ghost'}
size="sm"
class="h-8 rounded-none border-0"
class="h-8 rounded-none border-0 px-2"
onclick={() => setView('table')}
title="Table view"
aria-label="Table view"
>
<TableIcon class="mr-1 size-3.5" /> Table
<TableIcon class="size-3.5" />
</Button>
</div>
</div>
{#if view === 'table'}
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Filter by slug or name…" bind:value={query} class="h-8 max-w-xs text-xs" />
<Select.Root type="single" bind:value={typeFilter}>
<Select.Trigger class="h-8 w-40 text-xs">
{typeFilter === 'all' ? 'All types' : typeFilter}
</Select.Trigger>
<Select.Content>
<Select.Item value="all">All types</Select.Item>
{#each tableTypes as type}
<Select.Item value={type}>{type}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<div class="flex items-center gap-1.5">
<Switch id="show-inactive" bind:checked={showInactive} />
<Label for="show-inactive" class="text-xs font-normal text-muted-foreground">Inactive</Label>
</div>
<span class="text-xs text-muted-foreground">{filteredEntities.length} of {tableEntities.length}</span>
</div>
{:else}
<div class="flex flex-wrap items-center gap-2">
<Input placeholder="Root entity…" bind:value={graphRoot} class="h-8 max-w-44 text-xs" onchange={commitGraphQuery} />
<Input type="number" min="1" max="5" bind:value={graphDepth} class="h-8 w-16 text-xs" onchange={commitGraphQuery} />
<Input placeholder="Search / highlight…" bind:value={graphSearch} class="h-8 max-w-44 text-xs" />
<Button variant="outline" size="sm" class="h-8" onclick={resetGraph}>
<LocateFixedIcon class="mr-1 size-3.5" />
Reset
</Button>
<MultiSelectFilter label="Nodes" options={graphInfo.allNodeTypes} bind:selected={graphActiveNodeTypes} />
<MultiSelectFilter label="Edges" options={graphInfo.allRelTypes} bind:selected={graphActiveRelTypes} colorFor={relColorFor} />
<span class="ml-auto text-xs text-muted-foreground">
{graphInfo.visibleCount} nodes{graphInfo.truncated ? ' · truncated' : ''} · {graphInfo.zoomPct}%
</span>
</div>
{/if}
<!-- browse pane — selecting an entity opens it in a floating window
(EntityDesktop, mounted globally in App.svelte) instead of a sidebar. -->
(WindowLayer, mounted globally inside Desktop.svelte) instead of a sidebar. -->
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
{#if view === 'graph'}
<EntityGraph
{category}
selectedSlug={lastOpened}
onSelect={select}
bind:root={graphRoot}
depth={graphDepth}
search={graphSearch}
{search}
reloadToken={graphReloadToken}
resetToken={graphResetToken}
bind:activeNodeTypes={graphActiveNodeTypes}
activeNodeTypes={activeTypes}
bind:activeRelTypes={graphActiveRelTypes}
bind:info={graphInfo}
/>
{:else}
<EntityTable entities={filteredEntities} loading={tableLoading} selectedSlug={lastOpened} onSelect={select} {childToParent} />
<EntityTable entities={filteredEntities} loading={entitiesLoading} selectedSlug={lastOpened} onSelect={select} {childToParent} />
{/if}
</div>
</div>

View File

@@ -1,46 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchDashboardSummary, type DashboardSummary } from '$lib/api'
import { sessions, loadSessions, loadSessionMessages, newChat, sendMessage } from '$lib/stores/chat'
import { sessions, loadSessions } from '$lib/stores/chat'
import { openTaskWindow, openNewTaskWindow } from '$lib/stores/windows'
import { liveEvents, subscribeEvents } from '$lib/stores/events'
import { bucket, statusStyle, FILTERS, TASK_EVENTS, heading, type Bucket } from '$lib/tasks'
import { relativeTime } from '$lib/utils'
import GraphBackground from '$lib/components/GraphBackground.svelte'
import { Textarea } from '$lib/components/ui/textarea'
import { Button } from '$lib/components/ui/button'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert'
// ── Dashboard metrics (ported from the old Overview cards) ──────────────
let summary = $state<DashboardSummary | null>(null)
async function loadSummary() {
summary = await fetchDashboardSummary()
}
const totalEntities = $derived(
summary ? Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0) : 0
)
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'
)
import PlusIcon from '@lucide/svelte/icons/plus'
import type { Session } from '$lib/api'
// ── Task board ──────────────────────────────────────────────────────────
let filter = $state<'all' | Bucket>('all')
@@ -54,33 +22,13 @@
filter === 'all' ? $sessions : $sessions.filter((s) => bucket(s) === filter)
)
function openTask(id: string) {
loadSessionMessages(id)
location.hash = '#/chat'
}
// ── New task entry ──────────────────────────────────────────────────────
let input = $state('')
function submit() {
const text = input.trim()
if (!text) return
input = ''
newChat()
sendMessage(text)
location.hash = '#/chat'
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit()
}
function openTask(s: Session) {
openTaskWindow(s.id, heading(s))
}
onMount(() => {
loadSummary()
loadSessions()
const unsubStream = subscribeEvents()
const summaryTimer = setInterval(loadSummary, 15000)
// Refetch the board when a task's lifecycle changes anywhere. Scan all
// events newer than the last seen (entity.touched fires constantly and
@@ -102,161 +50,78 @@
return () => {
unsub()
unsubStream()
clearInterval(summaryTimer)
}
})
</script>
<div class="relative h-full overflow-hidden">
<div class="relative flex h-full flex-col gap-3 overflow-hidden p-4">
<GraphBackground />
<div class="relative z-10 h-full overflow-y-auto">
<!-- Hero: fills the viewport. The input is pinned to true vertical center via the
grid's middle 1fr row; the metrics strip and scroll hint sit in the auto rows
above/below without shifting it off-center. Scrolling lifts the whole hero to
reveal the table. -->
<section class="grid min-h-full grid-rows-[auto_1fr_auto] gap-6 px-4 py-16">
<!-- Metrics strip -->
<div class="flex flex-wrap items-center justify-center gap-2 text-xs">
<div class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur">
<span class="text-muted-foreground">Entities</span>
<span class="font-semibold tabular-nums">{totalEntities}</span>
{#if entityTypeCount}<span class="text-muted-foreground">· {entityTypeCount} types</span>{/if}
</div>
<div class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur">
<span
class="size-2 rounded-full {healthTone === 'ok' ? 'bg-success' : healthTone === 'degraded' ? 'bg-warning' : 'bg-destructive'}"
></span>
<span class="text-muted-foreground">Health</span>
<span class="font-semibold tabular-nums">{summary?.health.healthy ?? 0} / {totalMonitored}</span>
</div>
<button
type="button"
onclick={() => (location.hash = '#/signals')}
class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur transition-colors hover:border-primary/50"
>
{#if worstSeverity === 'critical'}
<TriangleAlertIcon class="size-3.5 text-destructive" />
{:else if worstSeverity === 'warning'}
<TriangleAlertIcon class="size-3.5 text-warning" />
{:else}
<CircleCheckIcon class="size-3.5 text-success" />
{/if}
<span class="text-muted-foreground">Signals</span>
<span class="font-semibold tabular-nums">{totalSignals}</span>
</button>
<button
type="button"
onclick={() => (location.hash = '#/ops')}
class="flex items-center gap-1.5 rounded-full border bg-card/60 px-3 py-1.5 backdrop-blur transition-colors hover:border-primary/50"
>
<span class="text-muted-foreground">Approvals</span>
<span class="font-semibold tabular-nums {summary?.approvals_pending ? 'text-destructive' : ''}"
>{summary?.approvals_pending ?? 0}</span
>
</button>
</div>
<div class="relative z-10 flex flex-wrap items-center gap-1.5">
{#each FILTERS as f}
<button
type="button"
onclick={() => (filter = f.id)}
class="rounded-full border px-2.5 py-1 text-xs transition-colors {filter === f.id
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-card/60 text-muted-foreground backdrop-blur hover:bg-muted/50'}"
>
{f.label}
<span class="ml-1 opacity-60">{counts[f.id] ?? 0}</span>
</button>
{/each}
<div class="flex-1"></div>
<Button size="sm" class="gap-1.5" onclick={openNewTaskWindow}>
<PlusIcon class="size-4" />
New task
</Button>
</div>
<!-- New task entry: centered in the middle (1fr) row -->
<div class="flex items-center justify-center">
<div class="w-full max-w-2xl text-center">
<h1 class="mb-1 text-2xl font-semibold tracking-tight">What should Nomos do?</h1>
<p class="mb-4 text-sm text-muted-foreground">
Describe a goal — Nomos will plan it, execute it, and report the outcome.
<div class="relative z-10 min-h-0 flex-1 overflow-auto rounded-xl border bg-card/70 backdrop-blur">
{#if visible.length === 0}
<div class="flex flex-col items-center gap-2 px-4 py-16 text-center">
<p class="max-w-sm text-sm text-muted-foreground">
{filter === 'all'
? 'No tasks yet. Start one and Nomos will plan it, execute it, and report the outcome.'
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`}
</p>
<form
class="relative rounded-2xl border bg-card/70 shadow-lg backdrop-blur focus-within:border-primary/60"
onsubmit={(e) => {
e.preventDefault()
submit()
}}
>
<Textarea
bind:value={input}
onkeydown={handleKeydown}
placeholder="e.g. Roll the staging database back to last night's snapshot and verify the app is healthy…"
rows={3}
class="max-h-52 min-h-24 resize-none border-0 bg-transparent px-4 py-3.5 text-base shadow-none focus-visible:ring-0"
/>
<div class="flex items-center justify-between px-3 pb-3">
<span class="text-[11px] text-muted-foreground">Enter to start · Shift+Enter for newline</span>
<Button type="submit" size="icon" disabled={!input.trim()} aria-label="Start task">
<ArrowUpIcon />
</Button>
</div>
</form>
</div>
</div>
<span class="justify-self-center text-[11px] text-muted-foreground/70">Scroll to see all tasks ↓</span>
</section>
<!-- Task table -->
<section class="mx-auto w-full max-w-5xl px-4 pb-16">
<div class="rounded-xl border bg-card/70 backdrop-blur">
<div class="flex flex-wrap items-center gap-1.5 border-b p-3">
{#each FILTERS as f}
<button
type="button"
onclick={() => (filter = f.id)}
class="rounded-full border px-2.5 py-1 text-xs transition-colors {filter === f.id
? 'border-primary bg-primary/10 text-foreground'
: 'border-border text-muted-foreground hover:bg-muted/50'}"
{:else}
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left text-xs text-muted-foreground">
<th class="w-36 px-4 py-2 font-medium">Status</th>
<th class="px-4 py-2 font-medium">Task</th>
<th class="hidden px-4 py-2 font-medium md:table-cell">Summary</th>
<th class="w-28 px-4 py-2 text-right font-medium">Last active</th>
</tr>
</thead>
<tbody>
{#each visible as s (s.id)}
{@const st = statusStyle(s)}
<tr
class="cursor-pointer border-b last:border-0 transition-colors hover:bg-muted/40"
onclick={() => openTask(s)}
>
{f.label}
<span class="ml-1 opacity-60">{counts[f.id] ?? 0}</span>
</button>
<td class="px-4 py-2.5">
<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>
</td>
<td class="max-w-0 px-4 py-2.5">
<span class="line-clamp-1 font-medium">{heading(s)}</span>
</td>
<td class="hidden max-w-0 px-4 py-2.5 md:table-cell">
<span class="line-clamp-1 text-xs text-muted-foreground">{s.summary || '—'}</span>
</td>
<td class="whitespace-nowrap px-4 py-2.5 text-right text-[11px] text-muted-foreground">
{relativeTime(s.last_active_at)}
</td>
</tr>
{/each}
</div>
{#if visible.length === 0}
<div class="flex flex-col items-center gap-2 px-4 py-16 text-center">
<p class="max-w-sm text-sm text-muted-foreground">
{filter === 'all'
? 'No tasks yet. Start one above and Nomos will plan it, execute it, and report the outcome.'
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`}
</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left text-xs text-muted-foreground">
<th class="w-36 px-4 py-2 font-medium">Status</th>
<th class="px-4 py-2 font-medium">Task</th>
<th class="hidden px-4 py-2 font-medium md:table-cell">Summary</th>
<th class="w-28 px-4 py-2 text-right font-medium">Last active</th>
</tr>
</thead>
<tbody>
{#each visible as s (s.id)}
{@const st = statusStyle(s)}
<tr
class="cursor-pointer border-b last:border-0 transition-colors hover:bg-muted/40"
onclick={() => openTask(s.id)}
>
<td class="px-4 py-2.5">
<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>
</td>
<td class="max-w-0 px-4 py-2.5">
<span class="line-clamp-1 font-medium">{heading(s)}</span>
</td>
<td class="hidden max-w-0 px-4 py-2.5 md:table-cell">
<span class="line-clamp-1 text-xs text-muted-foreground">{s.summary || '—'}</span>
</td>
<td class="whitespace-nowrap px-4 py-2.5 text-right text-[11px] text-muted-foreground">
{relativeTime(s.last_active_at)}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</section>
</tbody>
</table>
{/if}
</div>
</div>

View File

@@ -0,0 +1,193 @@
<script lang="ts">
// The desktop's Settings window — a generic shell (section list + content
// pane) so future settings just add a SECTIONS entry instead of a whole
// new window/screen. Connection reuses the same config.ts/oidc.ts calls as
// pages/Config.svelte (the full-page initial setup screen), just without
// that screen's "first run" framing — this is for changing settings while
// already inside the desktop.
import { Input } from '$lib/components/ui/input'
import { Label } from '$lib/components/ui/label'
import { Button } from '$lib/components/ui/button'
import { Separator } from '$lib/components/ui/separator'
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig, type OikosConfig } from '$lib/config'
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
import { getTheme, setTheme, THEME_LABELS, type Theme } from '$lib/stores/theme.svelte'
import { VERSION } from '$lib/version'
import { toast } from 'svelte-sonner'
import PlugIcon from '@lucide/svelte/icons/plug'
import PaletteIcon from '@lucide/svelte/icons/palette'
import LockIcon from '@lucide/svelte/icons/lock'
import LogInIcon from '@lucide/svelte/icons/log-in'
import ServerIcon from '@lucide/svelte/icons/server'
import CheckIcon from '@lucide/svelte/icons/check'
const SECTIONS = [
{ id: 'connection', label: 'Connection', icon: PlugIcon },
{ id: 'appearance', label: 'Appearance', icon: PaletteIcon }
] as const
type SectionId = (typeof SECTIONS)[number]['id']
let section = $state<SectionId>('connection')
const existing = getConfig()
let apiUrl = $state(existing.apiUrl ?? '')
let token = $state(existing.token ?? '')
let saving = $state(false)
let error = $state('')
let oidcLoggingIn = $state(false)
const oidcUser = getUser()
const oidcConfigured = isOIDCConfigured()
async function save() {
error = ''
if (!token.trim()) {
error = 'Token is required'
return
}
saving = true
const prev: OikosConfig = getConfig()
setConfig({ apiUrl: apiUrl.trim(), token: token.trim() })
initConfig({ apiUrl: apiUrl.trim(), token: token.trim() })
try {
const res = await fetchWithAuth('/api/v1/dashboard/summary')
if (!res.ok) {
error = res.status === 401 ? 'Invalid token' : `Server responded ${res.status}`
setConfig(prev)
initConfig(prev)
return
}
toast.success('Connection saved')
} catch {
error = 'Could not reach server — check the URL'
setConfig(prev)
initConfig(prev)
} finally {
saving = false
}
}
async function loginWithAuthentik() {
error = ''
oidcLoggingIn = true
setConfig({ apiUrl: apiUrl.trim(), token: '' })
initConfig({ apiUrl: apiUrl.trim(), token: '' })
try {
await startLogin()
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'OIDC login failed'
oidcLoggingIn = false
}
}
// Disconnecting invalidates the token every open window's data depends on
// — a reload is the simplest way back to a clean state (lands on the
// full-page Config screen since isConfigured() is now false).
function forgetConnection() {
clearConfig()
oidcLogout()
location.reload()
}
function pickTheme(t: Theme) {
setTheme(t)
}
</script>
<div class="flex h-full min-h-0 flex-col">
<div class="flex min-h-0 flex-1">
<nav class="flex w-44 shrink-0 flex-col gap-0.5 border-r bg-muted/20 p-2">
{#each SECTIONS as s (s.id)}
<button
type="button"
class="flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors {section === s.id
? 'bg-muted font-medium text-foreground'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}"
onclick={() => (section = s.id)}
>
<s.icon class="size-4 shrink-0" />
{s.label}
</button>
{/each}
</nav>
<div class="min-h-0 flex-1 overflow-y-auto p-6">
{#if section === 'connection'}
<div class="mx-auto flex max-w-md flex-col gap-5">
<div>
<h2 class="text-sm font-semibold">Connection</h2>
<p class="mt-0.5 text-xs text-muted-foreground">Where the control room talks to your Oikos server.</p>
</div>
{#if oidcUser}
<p class="text-xs text-muted-foreground">
Signed in via Authentik as <span class="font-medium text-foreground">{oidcUser}</span>
</p>
{/if}
<div class="flex flex-col gap-1.5">
<Label for="settings-server-url" class="text-xs font-medium">Server URL</Label>
<div class="relative">
<ServerIcon class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50" />
<Input id="settings-server-url" type="url" placeholder="https://oikos.hubris.network" class="pl-9" bind:value={apiUrl} />
</div>
</div>
<Button type="button" variant="secondary" disabled={oidcLoggingIn} onclick={loginWithAuthentik} class="w-full gap-2">
<LogInIcon class="size-4" />
{oidcLoggingIn ? 'Redirecting…' : oidcConfigured ? 'Switch account with Authentik' : 'Login with Authentik'}
</Button>
<div class="flex items-center gap-3">
<Separator decorative class="flex-1" />
<span class="text-[10px] font-medium uppercase tracking-widest text-muted-foreground/40">or use</span>
<Separator decorative class="flex-1" />
</div>
<form class="flex flex-col gap-2.5" onsubmit={(e) => { e.preventDefault(); save() }}>
<div class="relative">
<LockIcon class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/50" />
<Input type="password" placeholder="Bearer token" class="pl-9" bind:value={token} />
</div>
<Button type="submit" disabled={saving} class="w-full">
{saving ? 'Saving…' : 'Save'}
</Button>
</form>
{#if error}
<p class="rounded-lg bg-destructive/10 px-3 py-2 text-center text-sm text-destructive">{error}</p>
{/if}
{#if existing.token || oidcConfigured}
<div class="flex justify-center pt-1">
<Button type="button" variant="ghost" size="sm" onclick={forgetConnection}>Forget saved connection</Button>
</div>
{/if}
</div>
{:else if section === 'appearance'}
<div class="mx-auto flex max-w-md flex-col gap-5">
<div>
<h2 class="text-sm font-semibold">Appearance</h2>
<p class="mt-0.5 text-xs text-muted-foreground">Pick the theme for the whole desktop.</p>
</div>
<div class="flex flex-col gap-2">
{#each Object.entries(THEME_LABELS) as [id, label] (id)}
<button
type="button"
class="flex items-center justify-between rounded-lg border px-3.5 py-2.5 text-left text-sm transition-colors {getTheme() === (id as Theme)
? 'border-primary/50 bg-primary/5 text-foreground'
: 'text-muted-foreground hover:bg-muted/50'}"
onclick={() => pickTheme(id as Theme)}
>
{label}
{#if getTheme() === (id as Theme)}<CheckIcon class="size-4 text-primary" />{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
<div class="shrink-0 border-t px-3 py-1.5 text-right text-[11px] text-muted-foreground">Oikos {VERSION}</div>
</div>

17
web/src/test-setup.ts Normal file
View File

@@ -0,0 +1,17 @@
// jsdom doesn't implement matchMedia — anything that transitively imports
// theme.svelte.ts (which reads the OS color-scheme preference at module
// load) throws without this. Global vitest setup so every test file gets it
// for free instead of each one needing its own mock.
if (typeof window !== 'undefined' && !window.matchMedia) {
window.matchMedia = (query: string): MediaQueryList =>
({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false
}) as MediaQueryList
}

View File

@@ -3,6 +3,7 @@ import type { ProxyOptions } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
import { readFileSync, existsSync } from 'fs'
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
// In Docker, VERSION is copied into the build WORKDIR (/build/web/VERSION).
@@ -45,12 +46,27 @@ export default defineConfig({
__OIKOS_DEV_TOKEN__: JSON.stringify(process.env.OIKOS_API_TOKEN ?? ''),
},
resolve: {
alias: { $lib: '/src/lib' }
alias: {
$lib: '/src/lib',
// svelte-splitpanes imports SvelteKit's browser-detection module; this
// isn't a SvelteKit app, so point it at a plain shim (see the file).
// Needs a real filesystem path (not the /src/... shorthand $lib uses)
// so esbuild's dependency pre-bundler can resolve it too.
'$app/environment': fileURLToPath(new URL('./src/lib/shims/app-environment.ts', import.meta.url))
}
},
build: {
outDir: 'dist',
emptyOutDir: true
},
optimizeDeps: {
// svelte-splitpanes imports SvelteKit's $app/environment (aliased above
// to a shim), but esbuild's dependency pre-bundler resolves that
// differently and fails before the dev server even starts — skip
// pre-bundling it so it goes through Vite's normal (alias-aware)
// transform pipeline instead.
exclude: ['svelte-splitpanes']
},
server: {
proxy: {
'/api': authProxy('http://localhost:8090'),
@@ -63,6 +79,7 @@ export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['src/test-setup.ts'],
include: ['src/**/*.{test,spec}.{ts,js}']
}
})