oikos-web: extract the client stack from dtoro/oikos
Some checks failed
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Phase 1 of the hexagonal-architecture plan (dtoro/oikos
plans/2026-08-15-hexagonal-architecture.md). Moves the delivery stack
for the control-room UI into its own repo with its own pipeline:

- web/ — Svelte 5 SPA, verbatim (vendor/ included)
- desktop/ — Wails v3 wrapper, updateURL repointed to
  dtoro/oikos-web releases
- compose/ — Dockerfile + Caddyfile, verbatim (the /wails/* 404 and
  asset no-fallback quirks are load-bearing)
- docker-compose.yml — single web service, same 8091:80 publish,
  mem/cpu limits, and restart policy as the oikos stack's web service
- scripts/deploy.sh — mirrors oikos deploy essentials: CI-green gate,
  TOCTOU guard, version-tagged oikos-web:v$VERSION, prune to 3
- cmd/webhook + scripts/install-webhook.sh — standalone push-to-deploy
  receiver on :9798 (env-only secrets, no Infisical dependency)
- CI: the web job from oikos's ci.yml + the desktop build/release
  workflow, path-adjusted

Own VERSION (0.33.0) with the same bump-on-main rule; starts above
oikos's 0.32.x so the desktop updater sees an upgrade.
This commit is contained in:
dtoro
2026-08-15 22:20:54 +02:00
commit ed8a3145b3
365 changed files with 61308 additions and 0 deletions

4
web/.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
dist/
node_modules/
build/
package-lock.json

10
web/.prettierrc.json Normal file
View File

@@ -0,0 +1,10 @@
{
"useTabs": false,
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

17
web/components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "vega",
"tailwind": {
"css": "src/app.css",
"baseColor": "zinc"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}

39
web/eslint.config.js Normal file
View File

@@ -0,0 +1,39 @@
import js from '@eslint/js'
import ts from 'typescript-eslint'
import svelte from 'eslint-plugin-svelte'
import globals from 'globals'
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
{
files: ['**/*.{ts,js,svelte}'],
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: ['dist/', 'node_modules/', 'build/', '*.config.{ts,js}']
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
]
}
}
)

31
web/index.html Normal file
View File

@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Oikos</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="android-chrome-512.png" />
</head>
<body>
<div id="app"></div>
<script>
;(function () {
try {
var t = localStorage.getItem('oikos-theme')
if (!t) {
t = window.matchMedia('(prefers-color-scheme:light)').matches ? 'light' : 'dark'
}
if (t === 'dark') document.documentElement.classList.add('dark')
} catch (e) {}
})()
</script>
<script src="/wails/runtime.js"></script>
<script>
window.__OIKOS_CONFIG__ = {}
</script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

6425
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

59
web/package.json Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "oikos-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.25.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.3.2",
"@tsconfig/svelte": "^5.0.0",
"@types/d3-force": "^3.0.10",
"@vincjo/datatables": "^2.8.1",
"bits-ui": "^2.18.1",
"eslint": "^9.0.0",
"eslint-plugin-svelte": "^2.46.0",
"globals": "^15.0.0",
"jsdom": "^25.0.0",
"prettier": "^3.3.0",
"prettier-plugin-svelte": "^3.3.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-sonner": "^1.1.1",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.2",
"typescript": "^5.5.0",
"typescript-eslint": "^8.0.0",
"vite": "^6.0.0",
"vitest": "^2.0.0"
},
"dependencies": {
"@joan/procedural-glyph-engine": "file:../vendor",
"@sigma/node-image": "^3.0.0",
"@surdeddd/wmkit": "^0.3.0",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"dompurify": "^3.4.11",
"graphology": "^0.26.0",
"graphology-layout-forceatlas2": "^0.10.1",
"marked": "^18.0.5",
"sigma": "^3.0.3",
"svelte-splitpanes": "^8.0.12",
"tailwind-merge": "^3.6.0",
"uplot": "^1.6.32"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
web/public/favicon.png Normal file

Binary file not shown.

4
web/public/favicon.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 666 B

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,20 @@
Eggs
By Onocentaur
https://onocentaur.itch.io
March 2021
Description
350+ pixel art eggs for your next virtual pet/match 3/farming/holiday themed game.
This pack contains:
Over 350 16x16px eggs.
32 egg designs, 11 color variants.
2 different cracking animations for each egg.
Spritesheets for each color variant and cracking pattern.
Transparent PNGs.
Template files so you can color your own eggs.
Bonus: Letter & Number eggs.
Bonus: Incubator assets (nesting box and toggle-able lamp).
Bonus: 12 Animal assets.
Free to use for personal & professional projects. Attribution appreciated. If you use these assets in your project, let me know! I look forward to seeing what you make.

View File

@@ -0,0 +1,5 @@
CC0 1.0 Universal (CC0 1.0) Public Domain Dedication
The person who associated a work with this deed has dedicated the work to the public domain by waiving all of his or her rights to the work worldwide under copyright law, including all related and neighboring rights, to the extent allowed by law.
You can copy, modify, distribute and perform the work, even for commercial purposes, all without asking permission.

BIN
web/public/mascot/blink.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 B

BIN
web/public/mascot/hurt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 B

BIN
web/public/mascot/idle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

BIN
web/public/mascot/jump.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 B

BIN
web/public/mascot/peck.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 B

BIN
web/public/mascot/peep.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

BIN
web/public/mascot/sleep.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 B

BIN
web/public/mascot/walk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

BIN
web/public/mascot/walk2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

66
web/src/App.svelte Normal file
View File

@@ -0,0 +1,66 @@
<script lang="ts">
import Config from './pages/Config.svelte'
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 { onMount } from 'svelte'
import { processPendingCallback, initOIDC } from '$lib/oidc'
import { Toaster } from '$lib/components/ui/sonner'
let configured = $state(isConfigured())
// 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()) {
configured = true
} else if (!configured) {
if (await initOIDC()) configured = true
}
resolveLegacyHash()
})
// Context (dashboard summary + approvals poll) and the SSE stream both
// authenticate — don't subscribe until a token exists.
$effect(() => {
if (!configured) return
return subscribeContext()
})
</script>
{#if !configured}
<Config
onConnected={() => (configured = true)}
onCancel={isConfigured() ? () => (configured = true) : undefined}
/>
{:else}
<Toaster />
<Desktop />
{/if}

626
web/src/app.css Normal file
View File

@@ -0,0 +1,626 @@
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
/* ── Self-hosted type (cyberspace terminal aesthetic) ── */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/JetBrainsMono-Bold.woff2') format('woff2');
}
@font-face {
font-family: 'VT323';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/VT323-Regular.woff2') format('woff2');
}
/* bits-ui components (Slider, and any future orientation/disabled-aware
primitive) style themselves via shorthand data-* variants that Tailwind
v4 doesn't ship — it only auto-generates variants for bare boolean data
attributes (data-disabled), not attribute=value pairs like
data-orientation="horizontal". Without these, e.g. Slider's track silently
collapses to 0 height (no h-1.5 class survives), leaving only the thumb
visible with no visible rail. */
@custom-variant data-horizontal (&[data-orientation='horizontal']);
@custom-variant data-vertical (&[data-orientation='vertical']);
@custom-variant data-disabled (&[data-disabled]);
@theme inline {
--font-sans: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--font-heading: 'JetBrains Mono', ui-monospace, Menlo, monospace;
/* Square corners across the whole radius scale (--radius is pinned to 0
by both themes below). Kept as a 4-step scale so any future softer theme
can relax just --radius and get graded corners back for free. */
--radius-sm: var(--radius);
--radius-md: var(--radius);
--radius-lg: var(--radius);
--radius-xl: var(--radius);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-warning: var(--warning);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
}
/* ── Cyberspace Light (black ink on warm cream paper) ──
Ported from cyberspace.online's 3-color model (fg/bg/fgDim). Light and
dark are exact inverses of the same cream (#efe5c0). Emphasis is by
inversion (primary = fg ink), borders are fg-derived hairlines, and the
radius is 0 so every surface is square. */
:root {
--radius: 0px;
--background: #efe5c0;
--foreground: #000000;
--card: #efe5c0;
--card-foreground: #000000;
--popover: #efe5c0;
--popover-foreground: #000000;
--primary: #000000;
--primary-foreground: #efe5c0;
--secondary: #e0d6b0;
--secondary-foreground: #000000;
--muted: #e6dcc0;
--muted-foreground: #3a3a3a;
--accent: #000000;
--accent-foreground: #efe5c0;
--destructive: #9d0006;
--destructive-foreground: #efe5c0;
--border: color-mix(in oklab, #000000 22%, transparent);
--input: color-mix(in oklab, #000000 30%, transparent);
--ring: #000000;
--chart-1: #000000;
--chart-2: #3a3a3a;
--chart-3: #b57614;
--chart-4: #79740e;
--chart-5: #076678;
--sidebar: #efe5c0;
--sidebar-foreground: #000000;
--sidebar-primary: #000000;
--sidebar-primary-foreground: #efe5c0;
--sidebar-accent: #e0d6b0;
--sidebar-accent-foreground: #000000;
--sidebar-border: color-mix(in oklab, #000000 22%, transparent);
--sidebar-ring: #000000;
--success: #79740e;
--warning: #b57614;
--bg: var(--background);
--bg-surface: var(--card);
--bg-deeper: #e6dcc0;
--bg-hover: var(--secondary);
--bg-active: var(--accent);
--text: var(--foreground);
--text-muted: var(--muted-foreground);
--accent-blue: #076678;
--accent-green: var(--success);
--accent-red: var(--destructive);
--accent-orange: var(--warning);
}
/* ── Cyberspace Dark (warm cream on black) — exact inverse of Light ── */
.dark {
--radius: 0px;
--background: #000000;
--foreground: #efe5c0;
--card: #000000;
--card-foreground: #efe5c0;
--popover: #000000;
--popover-foreground: #efe5c0;
--primary: #efe5c0;
--primary-foreground: #000000;
--secondary: #1a1a1a;
--secondary-foreground: #efe5c0;
--muted: #141414;
--muted-foreground: #a89984;
--accent: #efe5c0;
--accent-foreground: #000000;
--destructive: #cc241d;
--destructive-foreground: #efe5c0;
--border: color-mix(in oklab, #efe5c0 22%, transparent);
--input: color-mix(in oklab, #efe5c0 30%, transparent);
--ring: #efe5c0;
--chart-1: #efe5c0;
--chart-2: #a89984;
--chart-3: #fabd2f;
--chart-4: #b8bb26;
--chart-5: #83a598;
--sidebar: #000000;
--sidebar-foreground: #efe5c0;
--sidebar-primary: #efe5c0;
--sidebar-primary-foreground: #000000;
--sidebar-accent: #1a1a1a;
--sidebar-accent-foreground: #efe5c0;
--sidebar-border: color-mix(in oklab, #efe5c0 22%, transparent);
--sidebar-ring: #efe5c0;
--success: #b8bb26;
--warning: #fabd2f;
--bg: var(--background);
--bg-surface: var(--card);
--bg-deeper: #050505;
--bg-hover: var(--secondary);
--bg-active: var(--accent);
--text: var(--foreground);
--text-muted: var(--muted-foreground);
--accent-blue: #83a598;
--accent-green: var(--success);
--accent-red: var(--destructive);
--accent-orange: var(--warning);
}
/* Terminal-style block cursor — outside @layer so it overrides CodeMirror */
.cm-cursor,
.cm-cursor-primary {
border-left-color: var(--primary) !important;
border-left-width: 0.5em !important;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html,
body {
@apply bg-background text-foreground;
height: 100%;
font-size: 15px;
line-height: 1.4;
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-heading);
}
::selection {
background: var(--primary);
color: var(--primary-foreground);
}
.cm-content {
background: var(--background);
}
/* Theme-aware scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--primary);
}
*::-webkit-scrollbar-corner {
background: transparent;
}
/* Pointer cursor on all interactive elements */
button:not(:disabled),
[role='button']:not([aria-disabled='true']),
a[href],
summary,
select {
cursor: pointer;
}
a,
[role='link'],
[role='tab'],
[role='option'],
[data-slot='popover-trigger'],
[data-slot='toggle-group-item'],
[data-slot='alert-dialog-action'],
[data-slot='alert-dialog-cancel'],
.cm-tooltip-autocomplete [role='option'] {
cursor: pointer;
}
[data-slot='table-container'] {
border: none;
box-shadow: none;
}
}
#app {
height: 100%;
}
/* wmkit floating windows (EntityDesktop.svelte) — mapped onto the app's own
card/border/ring tokens instead of an imported wmkit theme, so windows
follow the terracotta/dark theme toggle for free. */
[data-wm-desktop] {
overflow: visible;
}
[data-wm-window] {
display: flex;
flex-direction: column;
box-sizing: border-box;
pointer-events: auto;
background: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
/* Hard offset shadow (DOS-style), not a soft drop shadow — keeps the
border-driven system and still separates stacked windows, which matters
because --card now equals the desktop background. */
box-shadow: 3px 3px 0 0 var(--border);
overflow: hidden;
outline: none;
}
[data-wm-window][data-wm-focused] {
border-color: var(--ring);
box-shadow: 3px 3px 0 0 var(--ring);
}
[data-wm-window][data-wm-dragging],
[data-wm-window][data-wm-resizing] {
user-select: none;
}
[data-wm-window][data-wm-stage='minimized'] {
display: none;
}
[data-wm-resize] {
position: absolute;
}
a {
color: var(--accent-blue);
text-decoration: none;
}
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;
}
/* Base markdown rendering — used by every {@html marked.parse(...)} output
(EntityDetailContent, the Knowledge wiki's WikiReader, and as the
foundation ChatThread's fuller "Art Nouveau" chat styling builds on top
of). Global rather than a per-component <style> block: Svelte scopes
<style> to one component, so three separate copies of this same ~50-line
ruleset had accumulated (EntityDetailContent's copy was already a
documented "can't share, Svelte scopes styles" duplicate of ChatThread's,
and WikiReader added a third when the Knowledge wiki was built). Anything
that renders sanitized markdown into an .markdown-body container gets
this for free; a component only needs its own <style> block for looks
that genuinely diverge from this baseline (see ChatThread.svelte's
trimmed-down block for the pattern: same class, only the deltas kept,
using a two-class selector so its overrides win on specificity rather
than depending on <style> injection order).
Includes explicit list-style-type — Tailwind's preflight reset (@import
'tailwindcss' above) strips it from every <ul>/<ol>, so without this,
markdown bullet/numbered lists silently render with no markers. */
.markdown-body p {
margin: 0 0 0.5rem;
}
.markdown-body p:last-child {
margin-bottom: 0;
}
.markdown-body ul,
.markdown-body ol {
margin: 0 0 0.5rem;
padding-left: 1.25rem;
}
.markdown-body ul {
list-style-type: disc;
}
.markdown-body ol {
list-style-type: decimal;
}
.markdown-body li {
margin-bottom: 0.125rem;
}
.markdown-body code {
background: var(--muted);
border-radius: 4px;
padding: 0.1em 0.35em;
font-family: var(--font-mono);
font-size: 0.85em;
}
.markdown-body pre {
background: var(--muted);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.625rem 0.75rem;
overflow-x: auto;
margin: 0 0 0.5rem;
}
.markdown-body pre code {
background: none;
padding: 0;
font-size: 0.8125rem;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3 {
font-weight: 600;
margin: 0.75rem 0 0.375rem;
font-size: 1em;
}
.markdown-body table {
border-collapse: collapse;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.markdown-body th,
.markdown-body td {
border: 1px solid var(--border);
padding: 0.25rem 0.5rem;
text-align: left;
}
.markdown-body blockquote {
border-left: 3px solid var(--border);
padding-left: 0.75rem;
color: var(--muted-foreground);
margin: 0 0 0.5rem;
}
.markdown-body a {
color: var(--primary);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
.markdown-body a:hover {
text-decoration-style: solid;
}
/* ── cyberspace idioms (work in any theme; idiomatic for the terminal look) ── */
/* Pixel/terminal display face for stylized wordmarks & hero titles. Maps to
VT323 when available, JetBrains Mono fallback. Headings use --font-heading
(JetBrains Mono) by default; opt into this per-element for the "de-imagined"
title voice. */
.font-vt {
font-family: 'VT323', var(--font-mono);
letter-spacing: 0.02em;
}
/* Bordered square card with a 2px fg focus ring — the universal cyberspace
surface. Use on any container that wants the terminal-box look. */
.terminal-box {
border: 1px solid var(--border);
border-radius: 0;
}
.terminal-box:focus-within {
box-shadow: 0 0 0 2px var(--ring);
}
/* ════════════════════════════════════════════════════════════════════════
TERMINAL DESIGN SYSTEM — cyberspace.online adoption
These rules are UNLAYERED (written after Tailwind's @layer utilities),
so they override utility classes — shadow-*, ring-*, rounded-* — on the
shadcn data-slot primitives regardless of specificity. The system is:
• border-driven — hairline borders separate surfaces; no soft shadows
• square — every corner is 0 (also enforced via --radius tokens)
• focus by color — :focus signals via border/text color, not glow rings
• DOS modals — dialogs get a double fg-line frame + hatched corner
════════════════════════════════════════════════════════════════════════ */
:root {
--dos-border: 1px; /* DOS frame line width (used doubled for the modal edge) */
--dos-dither: 4px; /* hatch tile size for the modal corner shadow */
--dos-offset: 7px; /* how far the hatched corner sits out from the frame */
}
/* ── Containers → terminal-box: solid hairline border, square, no shadow.
Replaces shadcn's `shadow-xs ring-1 ring-foreground/10 rounded-xl/md`. ── */
[data-slot='card'],
[data-slot='popover-content'],
[data-slot='hover-card-content'],
[data-slot='dropdown-menu-content'],
[data-slot='select-content'],
[data-slot='tooltip-content'],
[data-slot='sheet-content'],
[data-slot='menubar-content'],
[data-slot='alert-dialog-content'] {
border: 1px solid var(--border);
border-radius: 0;
box-shadow: none;
}
/* ── Dialogs → the DOS frame. A 1px fg border, then a bg gap, then a 1px fg
ring (two stacked box-shadows) = the double-line terminal window edge. ── */
[data-slot='dialog-content'] {
position: fixed;
border: var(--dos-border) solid var(--foreground);
border-radius: 0;
box-shadow:
0 0 0 var(--dos-border) var(--background),
0 0 0 calc(var(--dos-border) * 2) var(--foreground);
}
/* Hatched corner shadow (fg-dim 45° checks) bottom-right — the DOS window
tell. Clipped to a small square outside the frame. */
[data-slot='dialog-content']::after {
content: '';
position: absolute;
right: calc(-1 * var(--dos-offset));
bottom: calc(-1 * var(--dos-offset));
width: var(--dos-offset);
height: var(--dos-offset);
pointer-events: none;
background-color: var(--muted-foreground);
background-image:
linear-gradient(
45deg,
var(--background) 25%,
transparent 25%,
transparent 75%,
var(--background) 75%
),
linear-gradient(
45deg,
var(--background) 25%,
transparent 25%,
transparent 75%,
var(--background) 75%
);
background-size: var(--dos-dither) var(--dos-dither);
background-position:
0 0,
calc(var(--dos-dither) / 2) calc(var(--dos-dither) / 2);
}
/* ── Overlays: opaque-ish theme background, no blur (terminal, not glass). ── */
[data-slot='dialog-overlay'],
[data-slot='alert-dialog-overlay'],
[data-slot='sheet-overlay'] {
background-color: color-mix(in oklab, var(--background) 82%, transparent);
backdrop-filter: none;
}
/* ── Inputs: square, no resting shadow; focus = solid fg border (cyberspace
signals focus by border color, not a glow ring). ── */
[data-slot='input'],
[data-slot='textarea'] {
border-radius: 0;
box-shadow: none;
}
[data-slot='input']:focus,
[data-slot='input']:focus-visible,
[data-slot='textarea']:focus,
[data-slot='textarea']:focus-visible {
border-color: var(--foreground);
box-shadow: none;
outline: none;
}
/* ── Buttons: square, no resting shadow. Focus keeps the cva border-color
shift to fg (focus-visible:border-ring) rather than a ring. ── */
[data-slot='button'] {
border-radius: 0;
box-shadow: none;
}
/* ── Badges & tabs: square. ── */
[data-slot='badge'] {
border-radius: 0;
}
[data-slot='tabs-trigger'],
[data-slot='tabs-list'] {
border-radius: 0;
}
/* ── Window controls (titlebar minimize/maximize/close + taskbar item close).
ONE style across all window chrome: bordered square, invert on hover — the
same idiom as desktop icons and taskbar buttons. Close is intentionally not
"danger"-colored so every control reads as the same component. ── */
.win-ctrl {
align-items: center;
justify-content: center;
border: 1px solid var(--border);
background: var(--background);
color: var(--muted-foreground);
transition:
color 0.12s,
border-color 0.12s,
background 0.12s;
}
.win-ctrl:hover {
border-color: var(--foreground);
background: var(--foreground);
color: var(--background);
}
/* Pin every window titlebar to one exact height, targeted by attribute so it
holds for every window regardless of its titlebar classes — including
already-open wmkit windows whose titlebar markup can lag behind on HMR
(component HMR is unreliable for wmkit windows; CSS HMR is not).
`flex: 0 0 2.25rem` is the strongest guarantee a flex item won't grow or
shrink — it stops a scrolling/growing content pane from compressing the
titlebar (the chat-window symptom). */
[data-wm-drag] {
flex: 0 0 2.25rem !important;
}

1167
web/src/lib/api.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
<script lang="ts">
// Notes — a trivial installable app demoing the App Store lifecycle.
// Installed from the App Store, gets a desktop icon, opens in a window,
// has its own localStorage-backed state, and uninstalls cleanly. No
// shell-internal imports — this is a self-contained app that could be
// shipped as a standalone bundle (Phase 4 will load such bundles from
// a URL; here it's bundled and discovered via the catalog).
let { storageKey = 'oikos-app-notes' }: { storageKey?: string } = $props()
let text = $state('')
let saved = $state(false)
function load(): string {
if (typeof localStorage === 'undefined') return ''
return localStorage.getItem(storageKey) ?? ''
}
function save(): void {
if (typeof localStorage === 'undefined') return
localStorage.setItem(storageKey, text)
saved = true
setTimeout(() => (saved = false), 1500)
}
function onKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
save()
}
}
text = load()
$effect(() => {
if (!text) return
const t = setTimeout(() => save(), 2000)
return () => clearTimeout(t)
})
</script>
<div class="flex h-full min-h-0 flex-col gap-2 p-4">
<div class="flex shrink-0 items-center justify-between">
<h2 class="text-sm font-medium">Notes</h2>
<span class="text-xs text-muted-foreground">
{#if saved}saved{:else}unsaved{/if}
</span>
</div>
<textarea
bind:value={text}
onkeydown={onKeydown}
placeholder="Type here. Auto-saves 2s after you stop, or Cmd/Ctrl+S."
class="min-h-0 flex-1 resize-none rounded-md border bg-background p-3 font-mono text-sm leading-relaxed focus-visible:outline-2 focus-visible:outline-ring"
></textarea>
<p class="shrink-0 text-xs text-muted-foreground">
A demo installable app — uninstall it from the App Store to remove its icon and window. Its
notes persist in localStorage under
<code class="font-mono">{storageKey}</code>.
</p>
</div>

View File

@@ -0,0 +1,81 @@
// App Store — installable app catalog + manifest format.
//
// This is Phase 3's "frontend scaffold, local bundles only" path: a static
// catalog of apps that ship with the build, each described by a persistable
// manifest (metadata) and resolved at runtime to a loader + icon (runtime
// bits that are NOT persisted — they're looked up from the catalog by
// manifest id on load). Installing an app = persisting its manifest id;
// uninstalling = removing it. The mechanism generalizes to remote bundles
// in Phase 4 by swapping the catalog for a fetched manifest + a
// `import(/* @vite-ignore */ entryUrl)` loader.
//
// Permissions are DECLARED on the manifest but NOT YET ENFORCED — that's
// Phase 4 (sandboxing). They're part of the contract now so a manifest
// author has to name what the app needs, and the operator can see it in
// the App Store before installing. Enforcement will land at the AppOS
// boundary (docs/mbse/components.md §9 "OS-service surface") in Phase 4.
import type { Component } from 'svelte'
import NotesIcon from '@lucide/svelte/icons/sticky-note'
// A permission an installable app can request. Maps 1:1 to entries in the
// AppOS table (docs/mbse/components.md §9). Phase 4 will enforce these at
// the store-access boundary; today they're declaration-only.
export type AppPermission =
| 'open-window' // openAppWindow / openEntityWindow / openTaskWindow
| 'read-context' // dashboard summary, subscribeContext
| 'read-events' // subscribeEvents (SSE)
| 'api:entities' // $lib/api entity endpoints
| 'api:knowledge' // knowledge search/content
| 'api:executions' // executions/approvals
| 'theme' // getTheme / setTheme
// Persistable metadata describing an installable app. This is what's
// stored in localStorage when an app is installed (just the manifest id is
// persisted, actually — the manifest is re-resolved from the catalog on
// load — but the shape is the unit of interchange and will be what a
// remote `/api/v1/apps` endpoint returns in Phase 4).
export interface AppManifest {
id: string
title: string
description: string
version: string
author?: string
permissions: AppPermission[]
docked?: boolean
noIcon?: boolean
width?: number
height?: number
minWidth?: number
minHeight?: number
}
// A catalog entry: the manifest (persistable metadata) plus the runtime
// bits the catalog resolves by id — the Lucide icon component and the
// dynamic-import loader. These runtime bits are never persisted; they're
// re-looked-up from this static catalog on every load.
export interface CatalogEntry {
manifest: AppManifest
icon: Component
load: () => Promise<{ default: Component }>
}
export const CATALOG: CatalogEntry[] = [
{
manifest: {
id: 'notes',
title: 'Notes',
description: 'A scratchpad. Auto-saves to localStorage. Demo installable app.',
version: '0.1.0',
author: 'oikos',
permissions: ['theme'],
width: 640,
height: 480,
minWidth: 360,
minHeight: 320
},
icon: NotesIcon,
load: () => import('./apps/Notes.svelte')
}
]
export const catalogById = new Map(CATALOG.map((e) => [e.manifest.id, e]))

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

@@ -0,0 +1,135 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// apps.ts holds only app metadata + a reactive registry. `component` is a
// dynamic-import loader, not the page itself, so importing apps.ts pulls no
// page modules. The install/uninstall tests touch localStorage and the
// module-scoped installedIds store, so each re-imports the module fresh (see
// docked.test.ts for the same pattern).
import { builtinApps, appWindowId, appIdFromWindowId } from './apps'
describe('builtinApps registry', () => {
it('has unique, non-empty ids', () => {
const ids = builtinApps.map((a) => a.id)
expect(ids.length).toBeGreaterThan(0)
expect(new Set(ids).size).toBe(ids.length)
for (const id of ids) expect(id).not.toBe('')
})
it('component is a loader function, not the component itself', () => {
for (const app of builtinApps) {
expect(typeof app.component).toBe('function')
}
})
it('every built-in is source: builtin', () => {
for (const app of builtinApps) expect(app.source).toBe('builtin')
})
it('windowed apps have positive default geometry', () => {
for (const app of builtinApps.filter((a) => !a.docked)) {
expect(app.width).toBeGreaterThan(0)
expect(app.height).toBeGreaterThan(0)
}
})
it('docked apps forbid window geometry', () => {
for (const app of builtinApps.filter((a) => a.docked)) {
expect(app.width).toBeUndefined()
expect(app.height).toBeUndefined()
expect(app.minWidth).toBeUndefined()
expect(app.minHeight).toBeUndefined()
}
})
it('includes the App Store and mascot as built-ins', () => {
expect(builtinApps.find((a) => a.id === 'app-store')).toBeTruthy()
const mascot = builtinApps.find((a) => a.id === 'mascot')
expect(mascot?.docked).toBe(true)
})
})
describe('appWindowId / appIdFromWindowId', () => {
it('round-trips an app id through its window id', () => {
for (const app of builtinApps) {
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', () => {
for (const app of builtinApps) {
expect(appWindowId(app.id).startsWith('app:')).toBe(true)
}
})
})
// install/uninstall lifecycle — each test re-imports fresh so the
// module-scoped installedIds store starts empty and localStorage is clean.
describe('install / uninstall', () => {
beforeEach(() => {
localStorage.clear()
vi.resetModules()
})
it('installApp adds a catalog app to the installed set', async () => {
const fresh = await import('./apps')
fresh.installApp('notes')
let snap: string[] = []
const unsub = fresh.installedAppIds.subscribe((v) => (snap = v))
expect(snap).toContain('notes')
unsub()
})
it('install is idempotent', async () => {
const fresh = await import('./apps')
fresh.installApp('notes')
fresh.installApp('notes')
let snap: string[] = []
fresh.installedAppIds.subscribe((v) => (snap = v))
expect(snap.filter((id) => id === 'notes')).toHaveLength(1)
})
it('installing an unknown manifest id is a no-op', async () => {
const fresh = await import('./apps')
fresh.installApp('does-not-exist')
let snap: string[] = []
fresh.installedAppIds.subscribe((v) => (snap = v))
expect(snap).not.toContain('does-not-exist')
})
it('uninstall removes the app', async () => {
const fresh = await import('./apps')
fresh.installApp('notes')
fresh.uninstallApp('notes')
let snap: string[] = []
fresh.installedAppIds.subscribe((v) => (snap = v))
expect(snap).not.toContain('notes')
})
it('uninstall is idempotent', async () => {
const fresh = await import('./apps')
expect(() => fresh.uninstallApp('notes')).not.toThrow()
})
it('persists the installed set to localStorage', async () => {
const fresh = await import('./apps')
fresh.installApp('notes')
const raw = localStorage.getItem('oikos-installed-apps')
expect(raw).toBeTruthy()
expect(JSON.parse(raw!)).toContain('notes')
})
it('drops persisted ids that no longer resolve to a catalog entry', async () => {
localStorage.setItem('oikos-installed-apps', JSON.stringify(['notes', 'removed-app']))
const fresh = await import('./apps')
let snap: string[] = []
fresh.installedAppIds.subscribe((v) => (snap = v))
expect(snap).toContain('notes')
expect(snap).not.toContain('removed-app')
})
})

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

@@ -0,0 +1,292 @@
// The app registry — single source of truth for what shows up as a desktop
// icon and what opens in its window.
//
// Two layers:
// - **Built-in apps** (always installed): the static `builtinApps` array
// below. These ship with the build and can't be removed.
// - **Installed apps** (operator-installed from the App Store): persisted
// manifest ids in localStorage, re-resolved against the catalog at
// load time. `installApp`/`uninstallApp` mutate this set.
//
// The public surface is reactive: `apps` is a derived store (built-in +
// installed) and `appById` is a derived Map. Consumers (Desktop.svelte,
// DockedLayer.svelte, Taskbar.svelte, icons.ts, windows.ts) subscribe or
// use `get()` for synchronous lookups. This is what lets an installed app
// appear on the desktop the moment it's registered, with no reload.
//
// App components are loaded lazily (`component: () => Promise<{ default:
// Component }>` — a dynamic-import loader). Desktop icons render from
// metadata alone; the chunk fetches on first window open, and Vite
// code-splits each app into its own chunk. See
// docs/mbse/components.md §9 for the full contract.
import type { Component } from 'svelte'
import { writable, derived, get, type Readable } from 'svelte/store'
import type { DashboardSummary } from '$lib/api'
import { openSignalCount } from '$lib/stores/context'
import {
catalogById,
type AppManifest,
type AppPermission,
type CatalogEntry
} from '$lib/app-store/catalog'
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
import BoxesIcon from '@lucide/svelte/icons/boxes'
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
import SirenIcon from '@lucide/svelte/icons/siren'
import SearchIcon from '@lucide/svelte/icons/search'
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
import SettingsIcon from '@lucide/svelte/icons/settings'
import EggIcon from '@lucide/svelte/icons/egg'
import StoreIcon from '@lucide/svelte/icons/store'
import Share2Icon from '@lucide/svelte/icons/share-2'
import ChartNetworkIcon from '@lucide/svelte/icons/chart-network'
export type { AppManifest, AppPermission }
// Two app kinds, picked by one flag:
// - Windowed (default): renders in a wmkit floating window. Geometry
// (width/height/min*) is required.
// - Docked (docked: true): renders on the Docked Layer above the window
// layer, with no window chrome and no taskbar button. Clicking its
// desktop icon toggles visibility (see stores/docked.ts) rather than
// opening a window. Geometry is forbidden — there is no window to size.
// Apps receive no props from the shell; they import the OS-service surface
// ($lib/stores/windows, $lib/stores/context, $lib/api, ...) directly. See
// docs/mbse/components.md §9 for the stable surface contract.
export interface AppDef {
id: string
title: string
icon: Component
// Dynamic-import loader. Invoked when an app window opens (windowed) or
// when the Docked Layer first mounts the app (docked). Vite's module cache
// makes the second open cheap (promise resolves from cache). The resolved
// module is a standard Svelte module namespace — `mod.default` is the
// component; LazyApp.svelte unwraps it.
component: () => Promise<{ default: Component }>
docked?: boolean
noIcon?: boolean
width?: number
height?: number
minWidth?: number
minHeight?: number
badge?: (summary: DashboardSummary | null) => number
// Source — 'builtin' (always installed) or 'installed' (from the App
// Store). Used by the App Store UI to distinguish uninstallable apps from
// built-ins.
source: 'builtin' | 'installed'
}
// Built-in apps — always installed, can't be removed. All components use
// dynamic-import loaders so apps.ts stays out of the page module graph at
// import time (Phase 2 code-splitting: each page is its own chunk, the
// main bundle stays small). The mascot uses the same path — deferring its
// module graph also breaks what would otherwise be a static cycle through
// icons.ts back to APPS.
export const builtinApps: AppDef[] = [
{
id: 'tasks',
title: 'Tasks',
icon: ListTodoIcon,
component: () => import('../pages/Overview.svelte'),
width: 960,
height: 680,
minWidth: 480,
minHeight: 420,
source: 'builtin'
},
{
// id stays 'kb' so persisted window geometry / desktop-icon position /
// the 'oikos-kb-view' preference survive the rename to "Fleet".
id: 'kb',
title: 'Fleet',
icon: BoxesIcon,
component: () => import('../pages/KnowledgeBase.svelte'),
width: 1000,
height: 700,
minWidth: 520,
minHeight: 420,
source: 'builtin'
},
{
id: 'ops',
title: 'Operations',
icon: ShieldCheckIcon,
component: () => import('../pages/Ops.svelte'),
width: 860,
height: 620,
minWidth: 480,
minHeight: 360,
badge: (s) => s?.approvals_pending ?? 0,
source: 'builtin'
},
{
id: 'signals',
title: 'Signals',
icon: SirenIcon,
component: () => import('../pages/Signals.svelte'),
width: 860,
height: 620,
minWidth: 480,
minHeight: 360,
badge: (s) => openSignalCount(s),
source: 'builtin'
},
{
id: 'knowledge',
title: 'Knowledge',
icon: SearchIcon,
component: () => import('../pages/Knowledge.svelte'),
width: 800,
height: 600,
minWidth: 440,
minHeight: 340,
source: 'builtin'
},
{
id: 'learning',
title: 'Learning',
icon: TrendingUpIcon,
component: () => import('../pages/Learning.svelte'),
width: 800,
height: 600,
minWidth: 440,
minHeight: 340,
source: 'builtin'
},
{
id: 'settings',
title: 'Settings',
icon: SettingsIcon,
component: () => import('../pages/Settings.svelte'),
width: 640,
height: 480,
minWidth: 480,
minHeight: 360,
source: 'builtin'
},
{
id: 'app-store',
title: 'App Store',
icon: StoreIcon,
component: () => import('../pages/AppStore.svelte'),
width: 720,
height: 560,
minWidth: 480,
minHeight: 360,
source: 'builtin'
},
{
id: 'mascot',
title: 'Cluck',
icon: EggIcon,
component: () => import('./mascot/MascotLayer.svelte'),
docked: true,
source: 'builtin'
},
{
id: 'entity-graph',
title: 'Entity Graph',
icon: ChartNetworkIcon,
component: () => import('../pages/EntityGraph.svelte'),
width: 1100,
height: 750,
minWidth: 640,
minHeight: 420,
source: 'builtin'
}
]
// --- Installed (operator-installed from the App Store) ---------------------
const INSTALLED_KEY = 'oikos-installed-apps'
function loadInstalled(): string[] {
if (typeof localStorage === 'undefined') return []
try {
const raw = localStorage.getItem(INSTALLED_KEY)
if (!raw) return []
const ids = JSON.parse(raw) as string[]
// Drop ids that no longer resolve to a catalog entry (the app was
// removed from the catalog in a later build) so they don't linger as
// phantom desktop icons.
return ids.filter((id) => catalogById.has(id))
} catch {
return []
}
}
// Persisted as the list of catalog manifest ids the operator has installed.
const installedIds = writable<string[]>(loadInstalled())
// Readable view for components (App Store UI) that need to re-render on
// install/uninstall. Mutations go through installApp/uninstallApp.
export const installedAppIds: Readable<string[]> = { subscribe: installedIds.subscribe }
function persist(ids: string[]): void {
if (typeof localStorage === 'undefined') return
localStorage.setItem(INSTALLED_KEY, JSON.stringify(ids))
}
installedIds.subscribe(persist)
function catalogEntryToAppDef(entry: CatalogEntry): AppDef {
const m = entry.manifest
return {
id: m.id,
title: m.title,
icon: entry.icon,
component: entry.load,
docked: m.docked,
noIcon: m.noIcon,
width: m.width,
height: m.height,
minWidth: m.minWidth,
minHeight: m.minHeight,
source: 'installed'
}
}
// The full app set: built-ins + installed catalog apps. Reactive so an
// install/uninstall is reflected on the desktop immediately, with no reload.
export const apps: Readable<AppDef[]> = derived(installedIds, (ids) => {
const installed = ids
.map((id) => catalogById.get(id))
.filter((e): e is CatalogEntry => !!e)
.map(catalogEntryToAppDef)
return [...builtinApps, ...installed]
})
export const appById: Readable<Map<string, AppDef>> = derived(
apps,
(list) => new Map(list.map((a) => [a.id, a]))
)
// Install/uninstall. Idempotent — installing an already-installed app or
// uninstalling a not-installed one is a no-op. Uninstalling a built-in is
// refused (built-ins can't be removed).
export function installApp(manifestId: string): void {
if (!catalogById.has(manifestId)) return
installedIds.update((ids) => (ids.includes(manifestId) ? ids : [...ids, manifestId]))
}
export function uninstallApp(manifestId: string): void {
installedIds.update((ids) => ids.filter((id) => id !== manifestId))
}
export function isInstalled(manifestId: string): boolean {
return get(installedIds).includes(manifestId)
}
// --- Window-id helpers (unchanged from the static-registry era) -----------
// Window ids are namespaced so WindowLayer.svelte can tell at a glance which
// content branch owns an id: `app:<id>` for registry apps, `session:<id>`
// 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

@@ -0,0 +1,954 @@
<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, type ActivityEntry } from '$lib/stores/activity'
import { resumeSession } from '$lib/api'
import type { Readable } from 'svelte/store'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import TurnTrace from './TurnTrace.svelte'
import ThinkingBlock from './ThinkingBlock.svelte'
import OperatorQuestion from './OperatorQuestion.svelte'
import GlyphIndicator from './GlyphIndicator.svelte'
import Spinner from './Spinner.svelte'
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
import SquareIcon from '@lucide/svelte/icons/square'
import CopyIcon from '@lucide/svelte/icons/copy'
import CheckIcon from '@lucide/svelte/icons/check'
import ArrowDownToLineIcon from '@lucide/svelte/icons/arrow-down-to-line'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import type { ChatMessage } from '$lib/stores/chat'
import type { ToolCallResult } from '$lib/types'
import type { PlanStep, SessionQuestion } from '$lib/api'
let {
messages,
streaming,
connectionState,
working = false,
error = null,
chatErrors = [],
onSend,
onCancel,
onReconnect,
onDismissError,
suggestions = [],
activityLog: activityLogProp = activityLog,
sessionId = null,
question = null,
initialDraft = '',
planSteps = [],
taskStatus,
lastActiveAt
}: {
messages: ChatMessage[]
streaming: boolean
connectionState: 'connected' | 'disconnected' | 'reconnecting'
/** True while a turn is running for this session — a live stream OR the
* server-side status says planning/executing. Drives the "working"
* indicator so a background/long/desynced turn still looks alive. The
* literal `streaming` (live deltas) is still used for the cursor + input
* lock. See plan 2026-08-03 F1. */
working?: boolean
error?: string | null
chatErrors?: { id: string; message: string; action?: string }[]
onSend: (text: string) => void
onCancel: () => void
onReconnect: () => void
onDismissError: (id: string) => void
suggestions?: string[]
activityLog?: Readable<ActivityEntry[]>
/** Session this thread's pending question (below) should post its answer against — see OperatorQuestion.svelte. */
sessionId?: string | null
/** The session's open operator question, if any — rendered as an inline card at the end of the thread (the newest thing, blocking the agent until answered). */
question?: SessionQuestion | null
/** Pre-fills the composer. Used by "Ask Nomos" in the entity window so an
* investigation starts from what the operator was just looking at, rather
* than making them retype it. Left editable on purpose — it is a starting
* point, not a command. */
initialDraft?: string
/** Current-generation plan steps for this session — rendered as a live
* checklist on the running turn (TodoWrite-style). Empty for a new/plan-less
* task and for the new-task launcher. */
planSteps?: PlanStep[]
/** Session status (active/planning/executing/…/done/failed). Drives the
* plan checklist's collapse-to-summary at a terminal state. */
taskStatus?: string
/** Session's last_active_at timestamp — used to detect a stuck turn
* (working but no activity for >5 min) and show elapsed time. */
lastActiveAt?: string
} = $props()
let input = $state(typeof initialDraft === 'string' ? initialDraft : '')
let scrolledUp = $state(false)
let container = $state<HTMLDivElement | null>(null)
let indicatorDone = $state(false)
let wasWorking = $state(false)
$effect(() => {
if (working) {
indicatorDone = false
wasWorking = true
}
if (!working && wasWorking) {
indicatorDone = true
const t = setTimeout(() => {
indicatorDone = false
wasWorking = false
}, 3000)
return () => clearTimeout(t)
}
})
const indicatorLabel = $derived.by(() => {
if (error) return error
if (!working && indicatorDone) return 'Done'
// Prefer the running PLAN STEP as the headline — it's stable across the
// step's many tool calls, so the line stops rewriting itself on every
// command (the "thinking overwrites itself" complaint, F6). Falls back to
// the current tool only when there's no active step (a plan-less Q&A or
// between steps), and to a plain "thinking…" otherwise.
const runningStep = $activityLogProp.find((e: ActivityEntry) => e.type === 'step_running')
if (runningStep) return runningStep.description
const runningTool = $activityLogProp.find((e: ActivityEntry) => e.type === 'tool_running')
if (runningTool) return runningTool.description
return 'Agent is thinking…'
})
// ── stuck detection + elapsed time ─────────────────────────────────────
// A turn is "stuck" when the server says working (planning/executing) but
// last_active_at is >5 min old — the agent's turn ended without updating
// the session status (crash, timeout, or a zombie gate). Show a distinct
// stuck indicator with a Resume button instead of a misleading "working…".
let resuming = $state(false)
let now = $state(Date.now())
$effect(() => {
if (!working) return
const id = setInterval(() => {
now = Date.now()
}, 1000)
return () => clearInterval(id)
})
const elapsedSeconds = $derived(
working && lastActiveAt
? Math.max(0, Math.floor((now - new Date(lastActiveAt).getTime()) / 1000))
: 0
)
const isStuck = $derived(working && !streaming && elapsedSeconds > 300)
function formatElapsed(s: number): string {
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.floor(s / 60)}m`
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
}
async function handleResume() {
if (!sessionId || resuming) return
resuming = true
try {
await resumeSession(sessionId)
} finally {
resuming = false
}
}
// ── glyph backdrop ─────────────────────────────────────────────────────
// The agent's live semantic state, rendered as a faint procedural glyph
// behind the transcript. Computed from the same signals as the sidebar
// (status / working / streaming / connection / stuck) so the backdrop
// breathes with the agent without any store imports (prop-driven).
const agentSprite = $derived.by(() => {
if (connectionState !== 'connected') return 'status.offline'
if (taskStatus === 'failed') return 'status.error'
if (taskStatus === 'abandoned') return 'status.cancelled'
if (taskStatus === 'done') return 'status.success'
if (taskStatus === 'awaiting_input') return 'ai.listening'
if (isStuck) return 'status.warning'
if (streaming) return 'ai.speaking'
if (working) return 'ai.still-working'
if (taskStatus === 'planning') return 'ai.thinking'
return 'ai.idle'
})
// 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)
// Keep the input pinned to inputMinSize (one line) until the user actually
// drags the splitter — not just on the first measurement. A floating
// window's threadHeight is 0/wrong for a frame or two while it animates
// open, and locking the percentage to that first reading left the input
// several lines tall once the window reached full size (fixed 2026-07-21).
let inputSize = $state(12)
let userResizedInput = false
$effect(() => {
if (!userResizedInput) inputSize = inputMinSize
})
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 (or a freshly-raised question) —
// unless user scrolled up to read. Sets scrollTop on the messages container
// directly instead of `scrollIntoView`, which walks ancestors and forces a
// reflow that can momentarily perturb the window titlebar height.
//
// During streaming, scroll INSTANTLY (behavior: 'auto') — the content is
// growing continuously, so a smooth animation constantly chases a moving
// target and produces the jerky "jumping" the operator sees. For
// non-streaming updates (a completed message, a question), a smooth scroll
// is fine. Uses requestAnimationFrame so the scroll lands after the DOM
// update, not 50ms later.
$effect(() => {
void messages
void question
if (streaming || !scrolledUp) {
const behavior = streaming ? ('auto' as const) : ('smooth' as const)
requestAnimationFrame(() => {
container?.scrollTo({ top: container.scrollHeight, behavior })
})
}
})
function render(text: string): string {
const renderer = new marked.Renderer()
renderer.code = function ({ text, lang }) {
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return `<div class="code-block-wrapper relative group"><pre><code class="language-${lang || 'plaintext'}">${escaped}</code></pre><button class="code-copy-btn" onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)" title="Copy" aria-label="Copy code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>`
}
renderer.table = function (token) {
const header = token.header.map((c: { text: string }) => `<th>${c.text}</th>`).join('')
const body = token.rows
.map((r: { text: string }[]) => `<tr>${r.map((c) => `<td>${c.text}</td>`).join('')}</tr>`)
.join('')
return `<div class="table-wrapper"><table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table></div>`
}
return DOMPurify.sanitize(marked.parse(text, { async: false, renderer }) as string)
}
function formatTime(iso: string): string {
try {
const d = new Date(iso)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
} catch {
return ''
}
}
function submit() {
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)
}
// Per-message copy affordance (border-driven icon button on each row).
let copiedId = $state<string | null>(null)
async function copyMessage(msg: ChatMessage) {
try {
await navigator.clipboard.writeText(msg.text)
copiedId = msg.id
setTimeout(() => {
if (copiedId === msg.id) copiedId = null
}, 1400)
} catch {
/* clipboard unavailable — silently no-op */
}
}
// Scroll-to-bottom: uses container.scrollTo (never scrollIntoView, which
// reflows ancestor wmkit panes — see wmkit.scrollintoview_reflow_pitfall).
function jumpToBottom() {
container?.scrollTo({ top: container.scrollHeight, behavior: 'smooth' })
scrolledUp = false
}
// Enrich a turn's tool calls with live `run` output AND plan-step
// attribution pulled from the activity log (keyed by tool id), so the inline
// TurnTrace can pin streaming output to its tool and group calls under their
// step. Run for every turn (not just the live one) so historical turns group
// correctly too; unmapped tools pass through unchanged.
function enrichTools(tools: ToolCallResult[], entries: ActivityEntry[]): ToolCallResult[] {
const byId = new Map<string, { liveOutput?: string; stepSeq?: number }>()
for (const e of entries) {
if (!e.id) continue
const cur = byId.get(e.id) ?? {}
if (e.liveOutput) cur.liveOutput = e.liveOutput
if (e.stepSeq != null) cur.stepSeq = e.stepSeq
byId.set(e.id, cur)
}
if (byId.size === 0) return tools
return tools.map((t) => {
if (!t.id) return t
const e = byId.get(t.id)
if (!e) return t
const next: ToolCallResult = { ...t }
if (e.liveOutput) next.liveOutput = e.liveOutput
if (e.stepSeq != null) next.stepSeq = e.stepSeq
return next
})
}
</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"
on:resize={() => (userResizedInput = true)}
>
<Pane class="flex flex-col">
<div class="relative min-h-0 flex-1">
<!-- Glyph backdrop — the agent's live semantic state as a faint
procedural watermark behind the transcript. Fixed (doesn't scroll
with the messages), pointer-events none, behind the content. -->
<div class="glyph-backdrop" aria-hidden="true">
<GlyphIndicator sprite={agentSprite} seed={sessionId ?? 'oikos'} size={640} opacity={0.07} />
</div>
<div class="relative z-[1] h-full overflow-y-auto" bind:this={container} onscroll={onScroll}>
<div class="mx-auto flex min-h-full max-w-3xl flex-col divide-y divide-border px-4">
{#if messages.length === 0}
<div class="flex flex-1 flex-col items-center justify-center gap-6 p-8 text-center">
<div>
<h2 class="text-xl font-semibold">Nomos</h2>
<p class="mt-1 text-sm text-muted-foreground">
Your resident operator. Ask about the fleet, or tell it to act.
</p>
</div>
{#if suggestions.length}
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
{#each suggestions as q}
<Button
variant="outline"
size="sm"
class="h-auto justify-start whitespace-normal py-2 text-left text-xs"
onclick={() => ask(q)}
>
{q}
</Button>
{/each}
</div>
{/if}
</div>
{/if}
{#each messages as msg, idx (msg.id)}
{@const isLast = idx === messages.length - 1}
<div class="msg-row relative flex gap-3 py-3">
<div class="msg-role" aria-hidden="true">{msg.role === 'user' ? 'YOU' : 'NOMOS'}</div>
<div class="msg-body min-w-0 flex-1">
{#if msg.role === 'user'}
<div class="user-text whitespace-pre-wrap text-sm leading-relaxed">{msg.text}</div>
{#if msg.created_at}
<div class="msg-time">{formatTime(msg.created_at)}</div>
{/if}
{#if isLast && working && !streaming}
<!-- The last message is this user row and the agent is working but not
live-streaming → the message was queued behind an in-flight turn
(plan 2026-08-03 F2). It'll run when the current step finishes. -->
<div class="queued-hint">Queued — runs when Nomos finishes the current step.</div>
{/if}
{:else}
{@const traceStatus = !isLast
? 'idle'
: error
? 'error'
: working
? 'running'
: indicatorDone
? 'done'
: 'idle'}
<!-- Inline progressive trace: live plan checklist (last turn) +
thinking line + per-tool lines, then the streamed answer. -->
{#if msg.tools.length > 0 || traceStatus !== 'idle'}
<TurnTrace
tools={enrichTools(msg.tools, $activityLogProp)}
status={traceStatus}
label={traceStatus === 'idle' ? null : indicatorLabel}
{isLast}
planSteps={isLast ? planSteps : []}
{taskStatus}
/>
{/if}
{#if msg.thinking}
<ThinkingBlock thinking={msg.thinking} />
{/if}
{#if msg.text}
<div
class="markdown-body prose-chat max-w-none text-sm leading-relaxed"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
{@html render(msg.text)}
{#if isLast && streaming}
<span class="stream-cursor" aria-hidden="true"></span>
{/if}
</div>
{#if msg.created_at}
<div class="msg-time">{formatTime(msg.created_at)}</div>
{/if}
{/if}
{/if}
</div>
<button
class="msg-action"
title="Copy message"
aria-label="Copy message"
onclick={() => copyMessage(msg)}
>
{#if copiedId === msg.id}<CheckIcon class="size-3.5" />{:else}<CopyIcon class="size-3.5" />{/if}
</button>
</div>
{/each}
{#if question}
<div class="py-3"><OperatorQuestion {sessionId} {question} /></div>
{/if}
</div>
</div>
{#if scrolledUp && messages.length > 0}
<button class="jump-bottom" onclick={jumpToBottom} aria-label="Jump to latest">
<ArrowDownToLineIcon class="size-4" />
</button>
{/if}
</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 animate-spin text-warning" aria-hidden="true" />
<span class="text-warning-foreground flex-1"
>Connection dropped — the task is still running and will catch up here automatically.
Reconnect to refresh now.</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}
{#if working && !streaming}
<!-- Background/autonomous turn in progress (no live stream to watch):
keep the composer open so the operator can queue a follow-up
(plan 2026-08-03 F1/F2). Lives in the message pane (alongside the
connection/error banners) so it consumes transcript space, NOT the
input pane's fixed height — otherwise appearing/disappearing would
clip the textarea and force a resize. Terminal status strip:
spinner + fg label + primary-tinted hairline border, aligned to the
textarea column. -->
<div class="mx-auto w-full max-w-3xl px-4">
{#if isStuck}
<div class="composer-status composer-status-stuck mb-2">
<span class="composer-status-label stuck-label">Stuck</span>
<span class="composer-status-text"
>no activity for {formatElapsed(elapsedSeconds)}</span
>
<Button
size="xs"
variant="outline"
class="h-6 text-[11px]"
onclick={handleResume}
disabled={resuming}
>
{resuming ? 'Resuming…' : 'Resume'}
</Button>
</div>
{:else}
<div class="composer-status mb-2">
<Spinner class="size-3 shrink-0 text-primary" />
<span class="composer-status-label">Working</span>
<span class="composer-status-text">{formatElapsed(elapsedSeconds)}</span>
</div>
{/if}
</div>
{/if}
</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-background 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 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"
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"
disabled={!input.trim()}
aria-label="Send"
>
<CornerDownLeftIcon class="size-3.5" />
</Button>
{/if}
</form>
</div>
</Pane>
</Splitpanes>
</div>
<style>
/* ── Cyberspace / terminal chat styling ──
Messages are full-width terminal log rows (left role-tag column +
content), separated by hairline divide-y. Border-driven, square, no soft
shadows — same language as the rest of the app. Prose deltas below sit on
top of the shared .markdown-body base (app.css); a two-class selector
(`.markdown-body.prose-chat`) wins on specificity over app.css's single
`.markdown-body` rules deterministically, regardless of <style> injection
order. */
/* Message rows */
.msg-row {
/* role column + body; the copy action is absolutely positioned top-right */
}
.msg-role {
flex-shrink: 0;
width: 3.25rem;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--muted-foreground);
padding-top: 0.15rem;
}
.msg-body {
display: flex;
flex-direction: column;
gap: 0.3rem;
overflow-wrap: break-word;
}
.user-text {
color: var(--foreground);
}
.msg-time {
font-family: var(--font-mono);
font-size: 9px;
color: var(--muted-foreground);
opacity: 0.7;
}
.queued-hint {
font-size: 10px;
color: var(--muted-foreground);
}
.msg-action {
position: absolute;
top: 0.6rem;
right: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: 1px solid transparent;
background: transparent;
color: var(--muted-foreground);
opacity: 0;
transition:
opacity 0.12s,
color 0.12s,
border-color 0.12s;
}
.msg-row:hover .msg-action,
.msg-action:focus-visible {
opacity: 1;
}
.msg-action:hover {
color: var(--foreground);
border-color: var(--border);
}
/* Glyph backdrop — fills the message pane, centers the glyph, stays put
while the transcript scrolls over it. pointer-events none so it never
intercepts scroll/click. */
.glyph-backdrop {
position: absolute;
inset: 0;
z-index: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
overflow: hidden;
}
/* Jump-to-latest button — border-driven square, sits over the transcript */
.jump-bottom {
position: absolute;
right: 1rem;
bottom: 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--border);
background: var(--background);
color: var(--muted-foreground);
box-shadow: 2px 2px 0 0 var(--border);
}
.jump-bottom:hover {
color: var(--foreground);
border-color: var(--foreground);
}
/* Prose deltas (markdown-body base lives in app.css). */
.prose-chat :global(li) {
padding-left: 0.25rem;
}
.prose-chat :global(li::marker) {
color: var(--primary);
}
.markdown-body.prose-chat :global(code) {
border: 1px solid var(--border);
padding: 0.15em 0.4em;
color: var(--primary);
}
.markdown-body.prose-chat :global(pre) {
padding: 0.75rem 0.875rem;
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) {
color: inherit;
border: none;
}
/* Headings — short accent rule under each; first heading in a message
doesn't get extra top margin. */
.markdown-body.prose-chat :global(h1) {
font-size: 1.15em;
margin: 1.15rem 0 0.4rem;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.markdown-body.prose-chat :global(h2) {
font-size: 1.08em;
margin: 1.15rem 0 0.4rem;
letter-spacing: 0.01em;
position: relative;
display: inline-block;
}
.markdown-body.prose-chat :global(h3) {
font-size: 1.02em;
margin: 1.15rem 0 0.4rem;
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;
background: linear-gradient(to right, var(--primary), transparent);
opacity: 0.55;
}
.prose-chat :global(.table-wrapper) {
overflow-x: auto;
margin: 0 0 0.5rem;
}
.prose-chat :global(.table-wrapper table) {
margin: 0;
}
.prose-chat :global(th) {
background: var(--muted);
font-weight: 600;
}
.markdown-body.prose-chat :global(th),
.markdown-body.prose-chat :global(td) {
padding: 0.3rem 0.6rem;
}
.markdown-body.prose-chat :global(blockquote) {
border-left: 3px solid var(--primary);
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
);
}
.prose-chat :global(strong) {
color: var(--foreground);
font-weight: 600;
}
.markdown-body.prose-chat :global(a) {
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
/* Input area hairline 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;
}
/* Composer "working/queued" status strip — terminal status-bar idiom: a
hairline primary-tinted border, square corners, a spinner + an uppercase
fg label + muted detail. Border-driven, no shadow. */
.composer-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.6rem;
border: 1px solid color-mix(in oklab, var(--primary) 35%, var(--border));
border-radius: 0;
background: color-mix(in oklab, var(--primary) 6%, var(--card));
font-size: 11px;
color: var(--muted-foreground);
}
.composer-status-label {
color: var(--foreground);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.composer-status-text {
color: var(--muted-foreground);
}
.composer-status-stuck {
border-color: color-mix(in oklab, var(--warning) 50%, var(--border));
background: color-mix(in oklab, var(--warning) 8%, var(--card));
}
.stuck-label {
color: var(--warning);
}
/* Code copy button — global: injected via render() into {html} blocks.
Square (cyberspace), not rounded. */
.prose-chat :global(.code-block-wrapper) {
position: relative;
}
.prose-chat :global(.code-copy-btn) {
position: absolute;
top: 0.375rem;
right: 0.375rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
color: var(--muted-foreground);
opacity: 0;
transition:
opacity 0.15s,
color 0.15s;
cursor: pointer;
border: none;
background: transparent;
}
.prose-chat :global(.code-block-wrapper:hover .code-copy-btn) {
opacity: 1;
}
.prose-chat :global(.code-copy-btn:hover) {
color: var(--foreground);
background: var(--muted);
}
/* Streaming cursor — blinking block appended after streaming text */
.stream-cursor {
display: inline-block;
width: 0.55em;
height: 1.1em;
background: var(--primary);
opacity: 0.75;
margin-left: 1px;
vertical-align: text-bottom;
animation: cursor-blink 0.9s ease-in-out infinite;
}
@keyframes cursor-blink {
0%,
100% {
opacity: 0.75;
}
50% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stream-cursor,
.msg-action {
animation: none;
transition: none;
}
}
</style>

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import { onMount } from 'svelte'
import { getTheme } from '$lib/stores/theme.svelte'
interface Particle {
x: number
y: number
vx: number
vy: number
r: number
phase: number
pulse: number
}
const COUNT = 90
const CONNECT_DIST = 160
const MOUSE_RADIUS = 200
const MOUSE_FORCE = 0.012
let host = $state<HTMLDivElement | null>(null)
let canvas = $state<HTMLCanvasElement | null>(null)
let particles: Particle[] = []
let mouse = { x: -500, y: -500 }
let w = 0,
h = 0,
dpr = 1
let timer: ReturnType<typeof setTimeout> | 0 = 0
function spawn() {
particles = Array.from({ length: COUNT }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.3,
vy: (Math.random() - 0.5) * 0.3,
r: 1 + Math.random() * 2,
phase: Math.random() * Math.PI * 2,
pulse: 0.4 + Math.random() * 0.6
}))
}
function resize() {
if (!host || !canvas) return
dpr = Math.min(window.devicePixelRatio || 1, 2)
w = host.clientWidth
h = host.clientHeight
canvas.width = Math.round(w * dpr)
canvas.height = Math.round(h * dpr)
if (particles.length === 0) spawn()
}
function onPointerMove(e: PointerEvent) {
if (!host) return
const rect = host.getBoundingClientRect()
mouse.x = e.clientX - rect.left
mouse.y = e.clientY - rect.top
}
function onPointerLeave() {
mouse.x = -500
mouse.y = -500
}
function draw(ts: number) {
timer = setTimeout(() => draw(performance.now()), 33)
if (!canvas || particles.length === 0) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const t = ts / 1000
const dark = getTheme() !== 'light'
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
// update + draw particles
for (const p of particles) {
// autonomous drift
p.vx += Math.sin(t * 0.4 + p.phase) * 0.003 * 0.15
p.vy += Math.cos(t * 0.35 + p.phase) * 0.003 * 0.15
// mouse interaction
const dx = p.x - mouse.x
const dy = p.y - mouse.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < MOUSE_RADIUS && dist > 0) {
const force = ((MOUSE_RADIUS - dist) / MOUSE_RADIUS) * MOUSE_FORCE
p.vx += (dx / dist) * force * 0.6
p.vy += (dy / dist) * force * 0.6
}
// friction + random nudge
p.vx *= 0.995
p.vy *= 0.995
if (Math.random() < 0.003) {
p.vx += (Math.random() - 0.5) * 0.04
p.vy += (Math.random() - 0.5) * 0.04
}
// wrap
p.x += p.vx
p.y += p.vy
if (p.x < -40) p.x = w + 40
if (p.x > w + 40) p.x = -40
if (p.y < -40) p.y = h + 40
if (p.y > h + 40) p.y = -40
// pulse brightness
const alpha = p.pulse * (0.35 + 0.15 * Math.sin(t * 1.2 + p.phase))
ctx.fillStyle = dark ? `rgba(168,153,132,${alpha})` : `rgba(58,58,58,${alpha})`
ctx.beginPath()
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
ctx.fill()
}
// connections between nearby particles
ctx.lineWidth = 0.6
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const a = particles[i]
const b = particles[j]
const dx = a.x - b.x
const dy = a.y - b.y
const dist = dx * dx + dy * dy
if (dist < CONNECT_DIST * CONNECT_DIST) {
const alpha = (1 - Math.sqrt(dist) / CONNECT_DIST) * 0.18
ctx.strokeStyle = dark ? `rgba(168,153,132,${alpha})` : `rgba(58,58,58,${alpha})`
ctx.beginPath()
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
ctx.stroke()
}
}
}
// radial scrim to keep center legible
const cx = w / 2,
cy = h / 2
const scrim = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.hypot(cx, cy))
const base = dark ? '0,0,0' : '239,229,192'
scrim.addColorStop(0, `rgba(${base},0.72)`)
scrim.addColorStop(0.35, `rgba(${base},0.40)`)
scrim.addColorStop(0.65, `rgba(${base},0.08)`)
scrim.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = scrim
ctx.fillRect(0, 0, w, h)
}
onMount(() => {
resize()
spawn()
const ro = new ResizeObserver(() => {
resize()
spawn()
})
if (host) ro.observe(host)
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerleave', onPointerLeave)
timer = setTimeout(() => draw(performance.now()), 33)
return () => {
clearTimeout(timer)
ro.disconnect()
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerleave', onPointerLeave)
}
})
</script>
<div bind:this={host} class="absolute inset-0 overflow-hidden bg-background">
<canvas bind:this={canvas} class="h-full w-full"></canvas>
</div>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import * as Collapsible from '$lib/components/ui/collapsible'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import type { Snippet } from 'svelte'
let {
title,
count,
defaultOpen,
children
}: {
title: string
count?: number
defaultOpen: boolean
children: Snippet
} = $props()
let open = $state(false)
$effect(() => {
open = defaultOpen
})
</script>
<Collapsible.Root bind:open class="rounded-md border bg-card">
<Collapsible.Trigger
class="flex w-full cursor-pointer select-none items-center justify-between gap-2 px-2 py-1 text-left hover:bg-muted/50"
>
<span class="text-xs font-medium">{title}{count !== undefined ? ` (${count})` : ''}</span>
<ChevronDownIcon
class="size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open
? 'rotate-180'
: ''}"
aria-hidden="true"
/>
</Collapsible.Trigger>
<Collapsible.Content
class="overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:animate-in data-[state=open]:fade-in"
>
<div class="border-t px-2 py-1.5">
{@render children()}
</div>
</Collapsible.Content>
</Collapsible.Root>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
let {
message = 'No items.',
colspan = 999,
class: className
}: {
message?: string
colspan?: number
class?: string
} = $props()
</script>
<tr>
<td
{colspan}
class={['py-8 text-center text-muted-foreground', className].filter(Boolean).join(' ')}
>
{message}
</td>
</tr>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,258 @@
<script lang="ts">
import type { Entity } from '$lib/api'
import * as Table from '$lib/components/ui/table'
import { Badge } from '$lib/components/ui/badge'
import { Skeleton } from '$lib/components/ui/skeleton'
import SortHeader from '$lib/components/data-table/SortHeader.svelte'
import EmptyState from '$lib/components/EmptyState.svelte'
import HealthDotRenderer from '$lib/components/data-table/renderers/HealthDotRenderer.svelte'
import StatusBadgeRenderer from '$lib/components/data-table/renderers/StatusBadgeRenderer.svelte'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
let {
entities,
loading,
selectedSlug = null,
onSelect,
childToParent = null
}: {
entities: Entity[]
loading: boolean
selectedSlug?: string | null
onSelect: (slug: string) => void
childToParent?: Map<string, string> | null
} = $props()
type SortKey = 'slug' | 'type' | 'name' | 'state' | 'health'
let sortKey = $state<SortKey>('slug')
let sortDir = $state<'asc' | 'desc'>('asc')
let collapsedNodes = $state<Set<string>>(new Set())
function toggleNode(slug: string, e: Event) {
e.stopPropagation()
const next = new Set(collapsedNodes)
if (next.has(slug)) next.delete(slug)
else next.add(slug)
collapsedNodes = next
}
function sortBy(key: SortKey) {
if (sortKey === key) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc'
} else {
sortKey = key
sortDir = 'asc'
}
}
function getSortState(key: SortKey) {
if (sortKey !== key) return { sorted: false, direction: 'asc' as const }
return { sorted: true, direction: sortDir }
}
const healthRank: Record<string, number> = {
down: 0,
degraded: 1,
stale: 2,
unknown: 3,
healthy: 4
}
function sortValue(entity: Entity, key: SortKey): string | number {
if (key === 'health') return entity.health ? (healthRank[entity.health] ?? -1) : -1
return (entity[key as keyof Entity] ?? '').toString().toLowerCase()
}
const sortedEntities = $derived.by(() => {
const sorted = [...entities].sort((a, b) => {
const av = sortValue(a, sortKey)
const bv = sortValue(b, sortKey)
if (av < bv) return -1
if (av > bv) return 1
return 0
})
if (sortDir === 'desc') sorted.reverse()
return sorted
})
const childrenByParent = $derived.by(() => {
const map = new Map<string, Entity[]>()
if (!childToParent) return map
const visibleSlugs = new Set(entities.map((e) => e.slug))
for (const e of sortedEntities) {
const parentSlug = childToParent.get(e.slug)
if (parentSlug && visibleSlugs.has(parentSlug)) {
if (!map.has(parentSlug)) map.set(parentSlug, [])
map.get(parentSlug)!.push(e)
}
}
return map
})
const nestedSlugs = $derived.by(() => {
const set = new Set<string>()
for (const children of childrenByParent.values()) for (const c of children) set.add(c.slug)
return set
})
const topLevelEntities = $derived.by(() =>
childToParent ? sortedEntities.filter((e) => !nestedSlugs.has(e.slug)) : sortedEntities
)
const skeletonSlugWidths = ['w-24', 'w-20', 'w-28', 'w-16', 'w-24', 'w-20', 'w-28', 'w-16']
const skeletonNameWidths = ['w-32', 'w-40', 'w-24', 'w-36', 'w-28', 'w-40', 'w-24', 'w-32']
</script>
{#if loading}
<div class="h-full min-h-0 overflow-hidden rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Slug</Table.Head>
<Table.Head>Type</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head>State</Table.Head>
<Table.Head>Health</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each skeletonSlugWidths as slugWidth, i}
<Table.Row class="hover:bg-transparent">
<Table.Cell><Skeleton class="h-4 {slugWidth}" /></Table.Cell>
<Table.Cell><Skeleton class="h-5 w-16 rounded-full" /></Table.Cell>
<Table.Cell><Skeleton class="h-4 {skeletonNameWidths[i]}" /></Table.Cell>
<Table.Cell><Skeleton class="h-5 w-14 rounded-full" /></Table.Cell>
<Table.Cell>
<div class="flex items-center gap-1.5">
<Skeleton class="size-2 shrink-0 rounded-full" />
<Skeleton class="h-4 w-12" />
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
{#snippet row(entity: Entity, level: number, ancestors: Set<string>)}
{@const ancestorsWithSelf = new Set(ancestors).add(entity.slug)}
{@const children = (childrenByParent.get(entity.slug) ?? []).filter(
(c) => !ancestorsWithSelf.has(c.slug)
)}
<Table.Row
class="cursor-pointer {entity.slug === selectedSlug ? 'bg-muted' : ''}"
role="row"
aria-level={level}
aria-expanded={children.length > 0 ? !collapsedNodes.has(entity.slug) : undefined}
tabindex={0}
onclick={() => onSelect(entity.slug)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect(entity.slug)
}
}}
>
<Table.Cell class="font-mono text-xs">
<span class="flex items-center gap-1" style="padding-left: {(level - 1) * 1.25}rem">
<span class="inline-flex size-3.5 shrink-0 items-center justify-center">
{#if children.length > 0}
<button
type="button"
class="rounded text-muted-foreground hover:text-foreground"
onclick={(e) => toggleNode(entity.slug, e)}
aria-label={collapsedNodes.has(entity.slug)
? `Expand ${entity.slug}`
: `Collapse ${entity.slug}`}
>
{#if collapsedNodes.has(entity.slug)}
<ChevronRightIcon class="size-3.5" />
{:else}
<ChevronDownIcon class="size-3.5" />
{/if}
</button>
{/if}
</span>
{entity.slug}
{#if children.length > 0}
<span class="text-muted-foreground">({children.length})</span>
{/if}
</span>
</Table.Cell>
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
<Table.Cell>{entity.name}</Table.Cell>
<Table.Cell>
<StatusBadgeRenderer value={entity.state ?? ''} kind="state" />
</Table.Cell>
<Table.Cell>
<HealthDotRenderer row={entity} value={null} />
</Table.Cell>
</Table.Row>
{#if children.length > 0 && !collapsedNodes.has(entity.slug)}
{#each children as child (child.id)}
{@render row(child, level + 1, ancestorsWithSelf)}
{/each}
{/if}
{/snippet}
<div class="h-full min-h-0 overflow-auto rounded-md border">
<Table.Root role={childToParent ? 'treegrid' : undefined}>
<Table.Header>
<Table.Row>
{@const ssSlug = getSortState('slug')}
<Table.Head>
<SortHeader
label="Slug"
sorted={ssSlug.sorted}
direction={ssSlug.direction}
onclick={() => sortBy('slug')}
/>
</Table.Head>
{@const ssType = getSortState('type')}
<Table.Head>
<SortHeader
label="Type"
sorted={ssType.sorted}
direction={ssType.direction}
onclick={() => sortBy('type')}
/>
</Table.Head>
{@const ssName = getSortState('name')}
<Table.Head>
<SortHeader
label="Name"
sorted={ssName.sorted}
direction={ssName.direction}
onclick={() => sortBy('name')}
/>
</Table.Head>
{@const ssState = getSortState('state')}
<Table.Head>
<SortHeader
label="State"
sorted={ssState.sorted}
direction={ssState.direction}
onclick={() => sortBy('state')}
/>
</Table.Head>
{@const ssHealth = getSortState('health')}
<Table.Head>
<SortHeader
label="Health"
sorted={ssHealth.sorted}
direction={ssHealth.direction}
onclick={() => sortBy('health')}
/>
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each topLevelEntities as entity (entity.id)}
{@render row(entity, 1, new Set())}
{:else}
<EmptyState message="No entities in this layer match the filter." colspan={5} />
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import * as Tabs from '$lib/components/ui/tabs'
let {
value = $bindable(''),
tabs,
class: className,
children
}: {
value?: string
tabs: {
value: string
label: string
count?: number
variant?: 'destructive' | 'default' | 'secondary' | 'outline'
}[]
class?: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children?: any
} = $props()
</script>
<Tabs.Root
bind:value
class={['flex flex-1 flex-col overflow-hidden', className].filter(Boolean).join(' ')}
>
<Tabs.List>
{#each tabs as tab}
<Tabs.Trigger value={tab.value}>
{tab.label}
{#if tab.count != null && tab.count > 0}
<slot name="badge-{tab.value}">
<!-- slot for custom badge rendering -->
</slot>
{/if}
</Tabs.Trigger>
{/each}
</Tabs.List>
{#if children}
{@render children()}
{/if}
</Tabs.Root>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import {
createGlyph,
type JoanGlyphEngine,
type SpriteName
} from '@joan/procedural-glyph-engine'
import { getTheme } from '$lib/stores/theme.svelte'
let {
sprite,
seed = 'oikos',
size = 96,
opacity = 1
}: {
sprite: string
seed?: string
/** Display max-width in px (the engine's internal grid stays 96; CSS
* upscales pixelated for larger backdrops). */
size?: number
/** Canvas opacity — <1 for a faint watermark backdrop. */
opacity?: number
} = $props()
let canvas = $state<HTMLCanvasElement | null>(null)
let glyph = $state<JoanGlyphEngine | null>(null)
function palette(t: 'light' | 'dark') {
return {
background: 'transparent',
off: t === 'dark' ? '#1a1a1a' : '#e6dcc0',
ink: t === 'dark' ? '#efe5c0' : '#000000',
accent: t === 'dark' ? '#a89984' : '#3a3a3a',
glow: 'transparent'
}
}
onMount(() => {
const g = createGlyph(canvas!, {
sprite: sprite as SpriteName,
seed,
gridSize: 96,
palette: palette(getTheme()),
background: false,
orbBackgroundColor: 'transparent',
orbBackgroundMode: 'none'
})
glyph = g
const obs = new MutationObserver(() => {
g.configure({ palette: palette(getTheme()) })
})
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
})
return () => obs.disconnect()
})
onDestroy(() => {
glyph?.destroy()
})
$effect(() => {
if (glyph && sprite) {
glyph.transitionTo(sprite as SpriteName)
}
})
</script>
<canvas
bind:this={canvas}
width="96"
height="96"
class="glyph"
style="max-width:{size}px;opacity:{opacity}"
aria-hidden="true"
></canvas>
<style>
.glyph {
display: block;
width: 100%;
aspect-ratio: 1;
margin: 0 auto;
image-rendering: pixelated;
}
</style>

View File

@@ -0,0 +1,89 @@
<script lang="ts">
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 = sessionId
const q = question
if (!sid || !q || !answer.trim() || submitting) return
submitting = true
const ok = await postAnswer(sid, q.id, answer.trim())
submitting = false
if (ok) freeText = ''
// No local optimistic clear: the question.answered event (which the POST
// triggers server-side) updates the store — this stays truthful if the
// POST reports ok but the event is somehow delayed.
}
</script>
{#if question}
{@const q = question}
<div class="flex flex-col gap-2 rounded-2xl border border-warning/30 bg-warning/5 px-3.5 py-3">
<div class="flex items-start gap-2">
<CircleHelpIcon class="mt-0.5 size-4 shrink-0 text-warning" />
<div class="min-w-0 flex-1">
<p class="text-sm font-medium leading-snug">{q.prompt}</p>
{#if q.context.why}
<p class="mt-0.5 text-xs text-muted-foreground">{q.context.why}</p>
{/if}
</div>
</div>
{#if q.context.entities?.length}
<div class="ml-6 flex flex-wrap gap-1">
{#each q.context.entities as slug}
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">{slug}</span>
{/each}
</div>
{/if}
{#if q.context.options?.length}
<div class="ml-6 flex flex-wrap gap-1.5">
{#each q.context.options as opt}
<Button
size="sm"
variant="outline"
class="h-7 px-2.5 text-xs"
disabled={submitting}
onclick={() => submit(opt)}
>
{opt}
</Button>
{/each}
</div>
{/if}
<div class="ml-6 flex items-end gap-1.5">
<Textarea
bind:value={freeText}
placeholder="Or type an answer…"
rows={1}
class="max-h-24 min-h-0 resize-none text-xs"
disabled={submitting}
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit(freeText)
}
}}
/>
<Button
size="sm"
class="h-7 px-2.5 text-xs"
disabled={!freeText.trim() || submitting}
onclick={() => submit(freeText)}
>
Send
</Button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,206 @@
<script lang="ts" module>
// Cached dither result for the current source/size, so a theme change only
// re-paints (cheap) instead of re-running the error-diffusion pass.
export interface DitherCache {
key: string
bits: Uint8Array // 1 = light (paper), 0 = dark (ink)
alpha: Uint8Array
w: number
h: number
}
</script>
<script lang="ts">
import { onMount } from 'svelte'
let {
src,
alt = '',
width = 256,
class: className = '',
plain = false,
bias = 0
}: {
src: string
alt?: string
/** CSS display width in px. The image is dithered at this resolution. */
width?: number
class?: string
/** Skip dithering; render the crisp source <img> instead. */
plain?: boolean
/** -255..255. Positive → more pixels resolve to ink (foreground). */
bias?: number
} = $props()
let canvas = $state<HTMLCanvasElement | null>(null)
let imgEl = $state<HTMLImageElement | null>(null)
let loaded = $state(false)
let tainted = $state(false)
let cache: DitherCache | null = null
const showSkeleton = $derived(!loaded)
const showCanvas = $derived(loaded && !plain && !tainted)
function readRgb(varName: string): [number, number, number] {
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim()
const m = raw.match(/#([0-9a-fA-F]{6})/)
const hex = m ? m[1] : '000000'
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]
}
function paint() {
if (!cache || !canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const { bits, alpha, w, h } = cache
canvas.width = w
canvas.height = h
// dark source (ink) → foreground; light source (paper) → background
const fg = readRgb('--foreground')
const bg = readRgb('--background')
const out = ctx.createImageData(w, h)
const d = out.data
for (let p = 0, i = 0; p < w * h; p++, i += 4) {
if (alpha[p] < 64) {
d[i + 3] = 0
continue
}
const c = bits[p] ? bg : fg
d[i] = c[0]
d[i + 1] = c[1]
d[i + 2] = c[2]
d[i + 3] = 255
}
ctx.putImageData(out, 0, 0)
}
function process(img: HTMLImageElement) {
const nw = img.naturalWidth || img.width
const nh = img.naturalHeight || img.height
if (!nw || !nh) return
const w = Math.max(1, Math.round(width))
const h = Math.max(1, Math.round((w * nh) / nw))
const off = document.createElement('canvas')
off.width = w
off.height = h
const octx = off.getContext('2d', { willReadFrequently: true })
if (!octx) return
octx.drawImage(img, 0, 0, w, h)
let data: Uint8ClampedArray
try {
data = octx.getImageData(0, 0, w, h).data
} catch {
tainted = true
return
}
const n = w * h
const lum = new Float32Array(n)
const alpha = new Uint8Array(n)
for (let p = 0, i = 0; p < n; p++, i += 4) {
lum[p] = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]
alpha[p] = data[i + 3]
}
const thr = 128 - bias
const bits = new Uint8Array(n)
// Atkinson error diffusion: 6 neighbors each get 1/8 of the quantization
// error. Softer & more "screen-printed" than FloydSteinberg.
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x
const oldv = lum[idx]
const newv = oldv < thr ? 0 : 255
bits[idx] = newv === 255 ? 1 : 0
const e = (oldv - newv) / 8
if (x + 1 < w) lum[idx + 1] += e
if (x + 2 < w) lum[idx + 2] += e
if (y + 1 < h) {
if (x - 1 >= 0) lum[idx + w - 1] += e
lum[idx + w] += e
if (x + 1 < w) lum[idx + w + 1] += e
}
if (y + 2 < h) lum[idx + 2 * w] += e
}
}
cache = { key: src + w, bits, alpha, w, h }
tainted = false
paint()
}
function onLoad() {
loaded = true
}
// Re-dither when the source image, target width, or plain flag changes.
$effect(() => {
void src
void width
void plain
if (!loaded || !imgEl || plain || tainted) return
if (imgEl.complete && imgEl.naturalWidth > 0) process(imgEl)
})
onMount(() => {
// Theme flip changes --foreground/--background on <html>'s class; re-paint
// the cached bits with the new palette (no re-dither needed).
const mo = new MutationObserver(() => {
if (cache && !plain) paint()
})
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] })
return () => mo.disconnect()
})
</script>
<div class="raster-wrap {className}" style="--rw:{width}px">
{#if showSkeleton}
<div class="raster-skeleton" aria-hidden="true"></div>
{/if}
{#if !plain}
<canvas
bind:this={canvas}
class="raster-canvas"
class:hidden={!showCanvas}
role="img"
aria-label={alt}
></canvas>
{/if}
<img
bind:this={imgEl}
{src}
{alt}
class="raster-fallback"
class:hidden={showCanvas}
onload={onLoad}
onerror={() => {
tainted = true
loaded = true
}}
/>
</div>
<style>
.raster-wrap {
display: inline-block;
position: relative;
width: var(--rw);
line-height: 0;
}
.raster-canvas,
.raster-fallback {
display: block;
width: 100%;
height: auto;
image-rendering: pixelated;
}
.raster-canvas.hidden,
.raster-fallback.hidden {
display: none;
}
/* No-flash placeholder while the source decodes — matches cyberspace's
raster-image-skeleton (empty, fills with the theme background). */
.raster-skeleton {
width: 100%;
min-height: calc(var(--rw) * 0.6);
aspect-ratio: 1 / 1;
background: var(--background);
}
</style>

View File

@@ -0,0 +1,119 @@
<script lang="ts">
// Floating-window content for a task/session — self-contained per
// sessionId via chat.ts's chatFor()/loadSessionChat()/sendSessionMessage()
// and workspace.ts's workspaceFor()/startSessionWorkspace(), so several of
// these can be open (and independently live) at once.
import { onDestroy, onMount } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import {
chatFor,
loadSessionChat,
sendSessionMessage,
cancelSessionStream,
stopSessionPolling,
dismissError,
chatErrors
} from '$lib/stores/chat'
import { activityLogFor } from '$lib/stores/activity'
import { workspaceFor, startSessionWorkspace, taskWorking, taskFor } from '$lib/stores/workspace'
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.
// sessionId is a stable prop (one per window mount, never changes), so
// capturing it at init is safe and intended.
// eslint-disable-next-line svelte/valid-compile
const chat = chatFor(sessionId)
const chatMessages = chat.messages
const chatStreaming = chat.streaming
// eslint-disable-next-line svelte/valid-compile
const chatWorking = taskWorking(sessionId)
const chatConnectionState = chat.connectionState
const chatError = chat.error
const chatNotFound = chat.notFound
// eslint-disable-next-line svelte/valid-compile
const sessionActivityLog = activityLogFor(sessionId)
// Started here (rather than left to TaskContextPanel's own onMount) so the
// workspace is already tracking touched entities/plan/questions before the
// context rail mounts.
// eslint-disable-next-line svelte/valid-compile
const workspace = workspaceFor(sessionId)
const planSteps = workspace.planSteps
// eslint-disable-next-line svelte/valid-compile
const chatTask = taskFor(sessionId)
const openQuestion = workspace.openQuestion
let loading = $state(true)
// F7 (plan 2026-08-03): the rail used to mount on demand (hasContext gate),
// which DESTROYED and remounted the ChatThread — losing the input draft and
// scroll position — and reflowed the chat column the moment the first
// activity/touched entity landed ("layout looks off when a chat goes from
// empty to content"). The layout is now stable from the moment the window
// opens: one Splitpanes, one ChatThread, the rail always present showing
// its own empty state ("Waiting for activity…") until there's something to
// show. A stable-but-initially-quiet rail is a better trade than a jumping
// layout.
// startSessionWorkspace's cleanup is registered via onDestroy below rather
// than returned from this callback — onMount ignores a returned function
// once the callback is async (its return value is a Promise, not the
// cleanup itself).
const stopWorkspace = startSessionWorkspace(sessionId)
onMount(async () => {
await loadSessionChat(sessionId)
loading = false
})
onDestroy(() => {
stopSessionPolling(sessionId)
stopWorkspace()
})
// Resizable right rail — sized smaller by default since task windows open
// narrower than the full page.
let railSize = $state(32)
</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}
working={$chatWorking}
connectionState={$chatConnectionState}
error={$chatError}
chatErrors={$chatErrors}
activityLog={sessionActivityLog}
{sessionId}
question={$openQuestion}
planSteps={$planSteps}
taskStatus={$chatTask?.status}
lastActiveAt={$chatTask?.last_active_at}
onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)}
onDismissError={dismissError}
/>
</Pane>
<Pane bind:size={railSize} minSize={24} maxSize={60}>
<TaskContextPanel {sessionId} />
</Pane>
</Splitpanes>
{/if}
</div>

View File

@@ -0,0 +1,650 @@
<script lang="ts">
import { onDestroy, untrack } from 'svelte'
import {
forceSimulation,
forceLink,
forceManyBody,
forceCenter,
forceCollide,
forceX,
forceY,
type Simulation
} from 'd3-force'
import { fetchGraph, type Entity } from '$lib/api'
import 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
vx?: number
vy?: number
fx?: number | null
fy?: number | null
degree: number
}
interface Edge {
source: string | Node
target: string | Node
type: string
}
// Probe/bookkeeping entity types are excluded — a health conversation
// mentions dozens of check:… slugs that would swamp the fleet topology.
const EXCLUDED = new Set(['check', 'execution'])
// Slug shape: lowercase type prefix, then one or more colon-separated
// segments (host:hubris, check:ping:8cf, lxc:caddy, investigation:foo/bar).
const SLUG_RE = /\b[a-z][a-z-]*:[a-z0-9][a-z0-9._/-]*(?::[a-z0-9._/-]+)*/g
let nodes = $state<Node[]>([])
let links = $state<Edge[]>([])
let selected = $state<Node | null>(null)
let sim: Simulation<Node, Edge> | null = null
// Non-reactive caches (persist across message deltas). resolvedVersion is a
// reactive counter bumped when async resolution finishes, so the reconcile
// effect re-runs once entities come back.
const resolvedCache = new Map<string, Node | null>()
const edgeCache: { source: string; target: string; type: string }[] = []
const edgeKeys = new Set<string>()
const resolving = new Set<string>()
let resolvedVersion = $state(0)
// container size drives the simulation coordinate space (1:1 with pixels so
// node dragging maps cleanly regardless of the resizable panel width).
let container = $state<HTMLDivElement | null>(null)
let cw = $state(300)
let ch = $state(300)
// View transform (zoom-to-fit + drag-pan). The force simulation runs in its
// own graph coordinate space; this maps graph→screen so every entity stays
// visible regardless of how far the layout spreads or how narrow the panel
// is. tx/ty are screen px; scale is unitless. `userPanned` pauses auto-fit
// once the operator drags the background, until the entity set changes or
// they double-click to reset.
let tx = $state(0)
let ty = $state(0)
let scale = $state(1)
let userPanned = $state(false)
const viewTransform = $derived(`translate(${tx},${ty}) scale(${scale})`)
function collectSlugs(value: unknown, out: Set<string>) {
if (typeof value === 'string') {
const m = value.match(SLUG_RE)
if (m) for (const s of m) out.add(s.replace(/[.,;)\]]+$/, ''))
} else if (Array.isArray(value)) {
for (const v of value) collectSlugs(v, out)
} else if (value && typeof value === 'object') {
for (const v of Object.values(value)) collectSlugs(v, out)
}
}
// Only pull from what the conversation is *about*: message text and the
// arguments the agent passed to tools — never bulk result rows (a single
// get_health_summary would otherwise dump all 168 entities into the graph).
const candidateSlugs = $derived.by(() => {
const out = new Set<string>()
for (const m of messages) {
collectSlugs(m.text, out)
for (const t of m.tools) collectSlugs(t.args, out)
}
return out
})
async function resolveSlugs(slugs: string[]) {
const todo = slugs.filter((s) => !resolvedCache.has(s) && !resolving.has(s))
if (!todo.length) return
for (const s of todo) resolving.add(s)
await Promise.all(
todo.map(async (s) => {
try {
const g = await fetchGraph({ root: s, depth: 1 })
const root = g?.nodes.find((n) => n.slug === s) ?? null
resolvedCache.set(s, root ? { ...root, degree: 0 } : null)
if (g && root) {
for (const e of g.edges) {
const k = `${e.source}|${e.target}|${e.type}`
if (!edgeKeys.has(k)) {
edgeKeys.add(k)
edgeCache.push({ source: e.source, target: e.target, type: e.type })
}
}
}
} catch {
resolvedCache.set(s, null)
} finally {
resolving.delete(s)
}
})
)
resolvedVersion++
}
function reconcile(cands: Set<string>) {
const desired: Node[] = []
const seen = new Set<string>()
for (const s of cands) {
const e = resolvedCache.get(s)
if (e && !EXCLUDED.has(e.type) && !seen.has(e.slug)) {
seen.add(e.slug)
desired.push(e)
}
}
const desiredSlugs = new Set(desired.map((e) => e.slug))
const current = nodes
const curSlugs = new Set(current.map((n) => n.slug))
let changed = desiredSlugs.size !== curSlugs.size
if (!changed)
for (const s of desiredSlugs)
if (!curSlugs.has(s)) {
changed = true
break
}
if (!changed) return
const bySlug = new Map(current.map((n) => [n.slug, n]))
const ls = edgeCache
.filter((e) => desiredSlugs.has(e.source) && desiredSlugs.has(e.target))
.map((e) => ({ ...e }))
const deg = new Map<string, number>()
for (const l of ls) {
deg.set(l.source as string, (deg.get(l.source as string) ?? 0) + 1)
deg.set(l.target as string, (deg.get(l.target as string) ?? 0) + 1)
}
const next = desired.map((e) => {
const p = bySlug.get(e.slug)
return { ...e, x: p?.x, y: p?.y, vx: p?.vx, vy: p?.vy, degree: deg.get(e.slug) ?? 0 }
})
nodes = next
links = ls
if (selected && !desiredSlugs.has(selected.slug)) selected = null
buildSim()
}
$effect(() => {
const cands = candidateSlugs
void resolvedVersion
const missing = [...cands].filter((s) => !resolvedCache.has(s))
if (missing.length) resolveSlugs(missing)
untrack(() => reconcile(cands))
})
function buildSim() {
sim?.stop()
if (!nodes.length) {
sim = null
return
}
sim = forceSimulation(nodes)
.force(
'link',
forceLink<Node, Edge>(links)
.id((n) => n.slug)
.distance(48)
.strength(0.5)
)
.force('charge', forceManyBody().strength(-150).distanceMax(240))
.force('center', forceCenter(cw / 2, ch / 2))
.force(
'collide',
forceCollide<Node>((n) => nodeRadius(n) + 6)
)
.force('x', forceX(cw / 2).strength(0.06))
.force('y', forceY(ch / 2).strength(0.06))
.velocityDecay(0.34)
.alphaDecay(0.045)
.on('tick', () => {
nodes = [...nodes]
if (!userPanned) fitView()
})
}
// keep the layout centred as the panel resizes
$effect(() => {
const w = cw
const h = ch
if (sim) {
sim.force('center', forceCenter(w / 2, h / 2))
sim.force('x', forceX(w / 2).strength(0.06))
sim.force('y', forceY(h / 2).strength(0.06))
sim.alpha(0.3).restart()
}
})
$effect(() => {
if (!container) return
const ro = new ResizeObserver((entries) => {
const r = entries[0].contentRect
cw = Math.max(r.width, 1)
ch = Math.max(r.height, 1)
})
ro.observe(container)
return () => ro.disconnect()
})
// The highlight ring/dim styling below is tied to the node whose window
// 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(() => {
if (selected && !$wmState.windows[selected.slug]) selected = null
})
onDestroy(() => sim?.stop())
const healthColor: Record<string, string> = {
healthy: 'var(--success)',
degraded: 'var(--warning)',
down: 'var(--destructive)',
stale: 'var(--warning)',
unknown: 'var(--muted-foreground)'
}
function nodeColor(n: Node): string {
return n.health
? (healthColor[n.health] ?? 'var(--muted-foreground)')
: 'var(--muted-foreground)'
}
function nodeRadius(n: Node): number {
return 6 + Math.min(Math.sqrt(n.degree) * 1.5, 6)
}
function shortName(slug: string): string {
return slug.split(':').pop() ?? slug
}
// Compute the view transform that fits every node (with label clearance)
// inside the panel, clamped so a single node doesn't fill it and a huge
// graph stays legible. No-op until the layout has positions / a size.
function fitView() {
if (!nodes.length || cw <= 1 || ch <= 1) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
if (n.x == null || n.y == null) continue
const r = nodeRadius(n) + 12 // node + label clearance
minX = Math.min(minX, n.x - r)
minY = Math.min(minY, n.y - r)
maxX = Math.max(maxX, n.x + r)
maxY = Math.max(maxY, n.y + r)
}
if (!Number.isFinite(minX)) return
const pad = 16
const w = Math.max(maxX - minX, 1)
const h = Math.max(maxY - minY, 1)
const s = Math.min((cw - pad * 2) / w, (ch - pad * 2) / h)
const clamped = Math.max(0.2, Math.min(2.5, Number.isFinite(s) ? s : 1))
scale = clamped
tx = (cw - w * clamped) / 2 - minX * clamped
ty = (ch - h * clamped) / 2 - minY * clamped
}
// When the entity SET changes (a new node added/removed), re-engage auto-fit
// so the new entity is brought into view. Same-slug re-renders (every sim
// tick) leave the signature unchanged and don't reset.
let lastMembership = ''
$effect(() => {
const sig = nodes
.map((n) => n.slug)
.sort()
.join('|')
if (sig !== lastMembership) {
lastMembership = sig
userPanned = false
}
})
// Live touch/health-diff lookups, keyed by slug for O(1) per-node checks
// during render. Kept as plain objects (not Maps) since Svelte 5 runes track
// 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
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
return m
})
const nowTouching = $derived(touched[0] ?? null)
function endpoint(end: string | Node): Node | undefined {
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
}
function endpointSlug(end: string | Node): string {
return typeof end === 'object' ? end.slug : end
}
// ─── drag / select / pan ─────────────────────────────────────────────
// A click (pointerdown+up with no movement in between) opens the entity
// straight in its own floating window (WindowLayer); `selected` only drives
// the highlight/dim styling. Node drag pins the node in GRAPH coords
// (screen→graph via the inverse view transform). Background drag pans the
// view and sets userPanned so auto-fit pauses. Double-click background
// re-fits all entities.
let dragState: { node: Node; moved: boolean } | null = null
let panState: { x: number; y: number } | null = null
function toGraph(clientX: number, clientY: number) {
const rect = container!.getBoundingClientRect()
return {
x: (clientX - rect.left - tx) / scale,
y: (clientY - rect.top - ty) / scale
}
}
function onNodeDown(e: PointerEvent, node: Node) {
e.stopPropagation()
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
dragState = { node, moved: false }
sim?.alphaTarget(0.2).restart()
}
function onBgDown(e: PointerEvent) {
panState = { x: e.clientX - tx, y: e.clientY - ty }
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
}
function onMove(e: PointerEvent) {
if (dragState) {
const p = toGraph(e.clientX, e.clientY)
dragState.node.fx = p.x
dragState.node.fy = p.y
dragState.moved = true
nodes = [...nodes]
return
}
if (panState) {
tx = e.clientX - panState.x
ty = e.clientY - panState.y
userPanned = true
}
}
function selectAndOpen(node: Node) {
selected = node
openEntityWindow(node.slug)
}
function onUp() {
if (dragState) {
const { node, moved } = dragState
node.fx = null
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) selectAndOpen(node)
return
}
panState = null
}
function refit() {
userPanned = false
fitView()
}
const selectedRelations = $derived(
selected
? links
.filter(
(l) =>
endpointSlug(l.source) === selected!.slug || endpointSlug(l.target) === selected!.slug
)
.map((l) => {
const outgoing = endpointSlug(l.source) === selected!.slug
return {
dir: outgoing ? '→' : '←',
type: l.type,
other: outgoing ? endpointSlug(l.target) : endpointSlug(l.source)
}
})
: []
)
</script>
<aside class="flex h-full min-h-0 flex-col bg-card">
{#if nowTouching}
<div
class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary"
>
<span class="size-1.5 animate-pulse rounded-full bg-primary"></span>
Now touching <code class="font-mono">{nowTouching.slug}</code>
</div>
{/if}
<div bind:this={container} class="relative min-h-0 flex-1 overflow-hidden">
{#if nodes.length === 0}
<div
class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center"
>
<svg viewBox="0 0 120 120" class="size-24 text-muted-foreground/40" fill="none">
<circle cx="60" cy="60" r="6" fill="currentColor">
<animate
attributeName="opacity"
values="0.4;1;0.4"
dur="2.4s"
repeatCount="indefinite"
/>
</circle>
<g stroke="currentColor" stroke-width="1" opacity="0.5">
<line x1="60" y1="60" x2="26" y2="34"
><animate
attributeName="opacity"
values="0.1;0.5;0.1"
dur="3s"
repeatCount="indefinite"
/></line
>
<line x1="60" y1="60" x2="96" y2="40"
><animate
attributeName="opacity"
values="0.1;0.5;0.1"
dur="3.4s"
repeatCount="indefinite"
/></line
>
<line x1="60" y1="60" x2="34" y2="92"
><animate
attributeName="opacity"
values="0.1;0.5;0.1"
dur="2.8s"
repeatCount="indefinite"
/></line
>
<line x1="60" y1="60" x2="92" y2="90"
><animate
attributeName="opacity"
values="0.1;0.5;0.1"
dur="3.1s"
repeatCount="indefinite"
/></line
>
</g>
<g fill="currentColor">
<circle cx="26" cy="34" r="3.5"
><animate
attributeName="opacity"
values="0.2;0.7;0.2"
dur="3s"
repeatCount="indefinite"
/></circle
>
<circle cx="96" cy="40" r="3.5"
><animate
attributeName="opacity"
values="0.2;0.7;0.2"
dur="3.4s"
repeatCount="indefinite"
/></circle
>
<circle cx="34" cy="92" r="3.5"
><animate
attributeName="opacity"
values="0.2;0.7;0.2"
dur="2.8s"
repeatCount="indefinite"
/></circle
>
<circle cx="92" cy="90" r="3.5"
><animate
attributeName="opacity"
values="0.2;0.7;0.2"
dur="3.1s"
repeatCount="indefinite"
/></circle
>
</g>
</svg>
<p class="max-w-[16rem] text-xs leading-relaxed text-muted-foreground">
Entities Nomos explores in this conversation appear here, wired up by their relationships.
</p>
</div>
{:else}
<svg
width={cw}
height={ch}
viewBox="0 0 {cw} {ch}"
class="h-full w-full touch-none select-none"
role="application"
aria-label="Session entity graph"
onpointerdown={onBgDown}
onpointermove={onMove}
onpointerup={onUp}
onpointercancel={onUp}
ondblclick={refit}
>
<defs>
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="0.8" fill="var(--border)" opacity="0.75" />
</pattern>
</defs>
<rect width={cw} height={ch} fill="url(#{dotGridId})" />
<g transform={viewTransform}>
<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)}
{@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>
</path>
{/if}
{/each}
</g>
<g>
{#each nodes as node (node.slug)}
{#if node.x != null && node.y != null}
{@const r = nodeRadius(node)}
{@const isSel = selected?.slug === node.slug}
{@const dim =
selected && !isSel && !selectedRelations.some((rel) => rel.other === node.slug)}
{@const isTouched = node.slug in touchedBySlug}
{@const diff = diffBySlug[node.slug]}
<g
transform="translate({node.x},{node.y})"
class="cursor-pointer"
opacity={dim ? 0.35 : 1}
role="button"
tabindex="0"
onpointerdown={(e) => onNodeDown(e, node)}
onkeydown={(e) => e.key === 'Enter' && selectAndOpen(node)}
>
{#if isSel}
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
{/if}
{#if isTouched}
<circle
r={r + 4}
fill="none"
stroke="var(--primary)"
stroke-width="1.5"
opacity="0.8"
>
<animate
attributeName="r"
values="{r + 3};{r + 8};{r + 3}"
dur="1.6s"
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0.8;0.1;0.8"
dur="1.6s"
repeatCount="indefinite"
/>
</circle>
{/if}
<circle
{r}
fill={nodeColor(node)}
stroke={isSel ? 'var(--foreground)' : 'var(--background)'}
stroke-width={isSel ? 2 : 1.5}
/>
<text
y={r + 10}
text-anchor="middle"
font-size="9"
fill={isSel ? 'var(--foreground)' : 'var(--muted-foreground)'}
paint-order="stroke"
stroke="var(--background)"
stroke-width="2.5"
class="pointer-events-none"
>
{shortName(node.slug)}
</text>
{#if diff}
<text
y={-r - 6}
text-anchor="middle"
font-size="8"
fill="var(--warning)"
paint-order="stroke"
stroke="var(--background)"
stroke-width="2.5"
class="pointer-events-none"
>
{diff.from}{diff.to}
</text>
{/if}
</g>
{/if}
{/each}
</g>
</g>
</svg>
{/if}
</div>
</aside>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
// A fading-blade spinner rather than a rotating arc — rotating a single
// thin stroke via CSS transform reads as jittery at icon sizes (the arc's
// sub-pixel edges shimmer each frame). Cycling opacity across fixed blades
// avoids that entirely and is how native OS spinners do it.
let { class: className = '' }: { class?: string } = $props()
const TICKS = 8
const DUR = 0.9
</script>
<svg viewBox="0 0 24 24" class={className} fill="none" aria-hidden="true">
{#each Array.from({ length: TICKS }) as _, i (i)}
<rect
x="11"
y="1.5"
width="2"
height="6"
rx="1"
fill="currentColor"
opacity="0.15"
transform="rotate({i * (360 / TICKS)} 12 12)"
>
<animate
attributeName="opacity"
values="1;0.15"
keyTimes="0;1"
dur="{DUR}s"
begin="{-(i * (DUR / TICKS)).toFixed(3)}s"
repeatCount="indefinite"
/>
</rect>
{/each}
</svg>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge'
type StatusKind = 'risk' | 'severity' | 'execution' | 'type' | 'default'
let {
value,
kind = 'default',
class: className
}: {
value: string
kind?: StatusKind
class?: string
} = $props()
const variantMap: Record<
StatusKind,
Record<string, 'default' | 'secondary' | 'destructive' | 'outline'>
> = {
risk: {
destructive: 'destructive',
config_mutation: 'secondary'
},
severity: {
critical: 'destructive',
warning: 'secondary',
info: 'default'
},
execution: {
failed: 'destructive',
denied: 'destructive',
revoked: 'destructive',
cancelled: 'destructive',
completed: 'default',
running: 'secondary',
approved: 'secondary'
},
type: {
runbook: 'secondary',
investigation: 'default'
},
default: {}
}
function variant(): 'default' | 'secondary' | 'destructive' | 'outline' {
return variantMap[kind]?.[value] ?? (kind === 'default' ? 'default' : 'outline')
}
</script>
<Badge variant={variant()} class={className}>{value}</Badge>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { onMount } from 'svelte'
import {
startWorkspace,
touched,
healthDiffs,
workspaceFor,
taskFor,
taskWorking,
currentWorking,
currentTask
} from '$lib/stores/workspace'
import { messages, chatFor, streaming, connectionState } from '$lib/stores/chat'
import SessionGraph from './SessionGraph.svelte'
let { sessionId = null }: { sessionId?: string | null } = $props()
onMount(() => (sessionId ? undefined : startWorkspace()))
const ws = $derived(sessionId ? workspaceFor(sessionId) : null)
const touchedStore = $derived(ws ? ws.touched : touched)
const healthDiffsStore = $derived(ws ? ws.healthDiffs : healthDiffs)
const chat = $derived(sessionId ? chatFor(sessionId) : null)
const messagesStore = $derived(chat ? chat.messages : messages)
// eslint-disable-next-line svelte/valid-compile
const taskWorkingStore = sessionId ? taskWorking(sessionId) : currentWorking
// eslint-disable-next-line svelte/valid-compile
const taskStreamingStore = sessionId ? chatFor(sessionId).streaming : streaming
// eslint-disable-next-line svelte/valid-compile
const taskConnStore = sessionId ? chatFor(sessionId).connectionState : connectionState
// eslint-disable-next-line svelte/valid-compile
const taskStatusStore = sessionId ? taskFor(sessionId) : currentTask
// ── stuck detection (mirrors ChatThread) ───────────────────────────────
let now = $state(Date.now())
$effect(() => {
if (!$taskWorkingStore) return
const id = setInterval(() => {
now = Date.now()
}, 1000)
return () => clearInterval(id)
})
const lastActiveAt = $derived($taskStatusStore?.last_active_at)
const isStuck = $derived(
$taskWorkingStore &&
!$taskStreamingStore &&
lastActiveAt &&
now - new Date(lastActiveAt).getTime() > 300_000
)
</script>
<div class="flex h-full min-h-0 flex-col">
<div class="shrink-0" style="aspect-ratio: 1; width: 100%;">
<SessionGraph
messages={$messagesStore}
touched={$touchedStore}
healthDiffs={$healthDiffsStore}
/>
</div>
</div>
<style>
</style>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import { Brain, ChevronRight } from '@lucide/svelte'
let { thinking }: { thinking: string } = $props()
let expanded = $state(false)
</script>
<div class="thinking-block">
<button
class="row"
onclick={() => (expanded = !expanded)}
aria-expanded={expanded}
>
<Brain class="icon size-3" />
<span class="text min-w-0 flex-1">
<span class="label">Thought{thinking.includes('\n') ? 's' : ''}</span>
</span>
<span class="summary">{thinking.slice(0, 60).replace(/\n/g, ' ')}{thinking.length > 60 ? '…' : ''}</span>
<ChevronRight class="chev size-3 {expanded ? 'open' : ''}" />
</button>
{#if expanded}
<div class="detail"><pre>{thinking}</pre></div>
{/if}
</div>
<style>
.thinking-block {
border-left: 1px solid var(--border);
padding-left: 0.5rem;
animation: thinking-in 0.15s ease-out;
}
@keyframes thinking-in {
from { opacity: 0; transform: translateY(-2px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.thinking-block { animation: none; }
}
.row {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.2rem 0;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
}
.row:hover .label {
color: var(--foreground);
}
.icon {
flex-shrink: 0;
color: var(--muted-foreground);
}
.label {
font-size: 12px;
color: var(--muted-foreground);
}
.text {
display: flex;
align-items: baseline;
gap: 0.25rem;
}
.summary {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 45%;
opacity: 0.6;
}
.chev {
flex-shrink: 0;
color: var(--muted-foreground);
opacity: 0.6;
transition: transform 0.12s;
}
.chev.open {
transform: rotate(90deg);
}
.detail {
padding: 0.25rem 0 0.4rem 1.25rem;
}
.thinking-block :global(pre) {
margin: 0;
max-height: 16rem;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
background: var(--muted);
border: 1px solid var(--border);
padding: 0.4rem 0.5rem;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.4;
color: var(--foreground);
}
@media (prefers-reduced-motion: reduce) {
.chev { transition: none; }
}
</style>

View File

@@ -0,0 +1,258 @@
<script lang="ts" module>
// One tool call rendered as a compact, progressive line — the Claude-Code
// signature for the inline trace. Collapsed: state icon + humanized label +
// a one-line RESULT summary on completion (or a "live" tag while a `run`
// streams). Expanded (click): raw args/result/error in opaque <pre> blocks.
// Border-driven, square, no rounded/shadow (cyberspace system).
</script>
<script lang="ts">
import { Check, ChevronRight, Loader2, X } from '@lucide/svelte'
import type { ToolCallResult } from '$lib/types'
import { toolActivityLabel, toolResultSummary } from '$lib/stores/activity'
let { tool }: { tool: ToolCallResult } = $props()
let expanded = $state(false)
let liveEl = $state<HTMLPreElement | null>(null)
const status = $derived(tool.type === 'tool_use' ? 'running' : tool.error ? 'error' : 'done')
const label = $derived(toolActivityLabel(tool))
const summary = $derived(toolResultSummary(tool))
// Tool calls start COLLAPSED — the operator expands them on demand. The
// live `run` output is shown in a separate pinned-tail mini pane below the
// collapsed row (not by auto-opening the whole detail), so the line stays
// compact while the command streams. Previously `open` auto-expanded on
// liveOutput and then collapsed when it cleared — the "start open, then
// collapse" behavior the operator found confusing.
const open = $derived(expanded)
$effect(() => {
if (tool.liveOutput && liveEl) liveEl.scrollTop = liveEl.scrollHeight
})
const hasDetail = $derived(
!!tool.args || (tool.result !== undefined && tool.result !== null) || !!tool.error
)
function pretty(v: unknown): string {
if (typeof v === 'string') {
try {
return JSON.stringify(JSON.parse(v), null, 2)
} catch {
return v
}
}
try {
return JSON.stringify(v, null, 2)
} catch {
return String(v)
}
}
</script>
<div class="tool-line">
<button
class="row"
onclick={() => (expanded = !expanded)}
aria-expanded={open}
disabled={!hasDetail && !tool.liveOutput}
>
<span class="icon {status}" aria-hidden="true">
{#if status === 'running'}
<Loader2 class="size-3 animate-spin" />
{:else if status === 'error'}
<X class="size-3" />
{:else}
<Check class="size-3" />
{/if}
</span>
<span class="text min-w-0 flex-1">
<span class="label {status === 'done' ? 'done-text' : ''}">{label}</span>
</span>
{#if status === 'running' && tool.liveOutput}
<span class="live-tag"><Loader2 class="size-2.5 animate-spin" /> live</span>
{:else if status === 'done' && summary}
<span class="summary">{summary}</span>
{:else if status === 'error'}
<span class="summary err">error</span>
{/if}
{#if hasDetail || tool.liveOutput}
<ChevronRight class="chev size-3 {open ? 'open' : ''}" />
{/if}
</button>
{#if tool.liveOutput}
<!-- Live `run` output — pinned-tail mini pane, always visible while the
command streams. Separate from the expand/collapse state so the tool
line itself stays collapsed. -->
<div class="live-output">
<pre bind:this={liveEl} class="live">{tool.liveOutput}</pre>
</div>
{/if}
{#if open}
<div class="detail">
{#if tool.args}
<div class="khead">Args</div>
<pre>{pretty(tool.args)}</pre>
{/if}
{#if tool.result !== undefined && tool.result !== null}
<div class="khead">Result</div>
<pre class={status === 'error' ? 'err' : ''}>{pretty(tool.result)}</pre>
{/if}
{#if tool.error}
<div class="khead err">Error</div>
<pre class="err">{tool.error}</pre>
{/if}
</div>
{/if}
</div>
<style>
.tool-line {
border-left: 1px solid var(--border);
padding-left: 0.5rem;
animation: tool-line-in 0.15s ease-out;
}
@keyframes tool-line-in {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.tool-line {
animation: none;
}
}
.row {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.2rem 0;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
}
.row:disabled {
cursor: default;
}
.row:not(:disabled):hover .label {
color: var(--foreground);
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 0.75rem;
flex-shrink: 0;
}
.icon.running {
color: var(--primary);
}
.icon.error {
color: var(--destructive);
}
.icon.done {
color: var(--muted-foreground);
}
.label {
font-size: 12px;
color: var(--foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label.done-text {
color: var(--muted-foreground);
}
.text {
display: flex;
align-items: baseline;
gap: 0.25rem;
}
.summary {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 45%;
}
.summary.err {
color: var(--destructive);
}
.live-tag {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.2rem;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--primary);
}
.chev {
flex-shrink: 0;
color: var(--muted-foreground);
opacity: 0.6;
transition: transform 0.12s;
}
.chev.open {
transform: rotate(90deg);
}
.detail {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.25rem 0 0.4rem 1.25rem;
}
.live-output {
padding: 0.1rem 0 0.3rem 1.25rem;
}
.live-output :global(pre.live) {
max-height: 8rem;
}
.khead {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted-foreground);
}
.khead.err {
color: var(--destructive);
}
.tool-line :global(pre) {
margin: 0;
max-height: 12rem;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
background: var(--muted);
border: 1px solid var(--border);
padding: 0.4rem 0.5rem;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.4;
color: var(--foreground);
}
.tool-line :global(pre.err) {
color: var(--destructive);
border-color: color-mix(in oklab, var(--destructive) 40%, var(--border));
background: color-mix(in oklab, var(--destructive) 6%, var(--muted));
}
@media (prefers-reduced-motion: reduce) {
.chev {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,325 @@
<script lang="ts" module>
// The agent's working trace for ONE assistant turn, rendered inline as a
// progressive Claude-Code-style stream instead of a collapsed blob (replaces
// AgentTrace). Top to bottom: live plan checklist (running turn only), a
// "Thinking…" line while the model reasons (before the first tool / between
// steps), then each tool call as its own compact line grouped under its plan
// step. The streamed text answer is rendered by ChatThread after this.
</script>
<script lang="ts">
import type { ToolCallResult } from '$lib/types'
import type { PlanStep } from '$lib/api'
import ToolLine from './ToolLine.svelte'
import Spinner from './Spinner.svelte'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import PauseIcon from '@lucide/svelte/icons/pause'
import SlashIcon from '@lucide/svelte/icons/slash'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
let {
tools = [],
status = 'idle',
label = null,
isLast = false,
planSteps = [],
taskStatus
}: {
tools?: ToolCallResult[]
/** `idle` = this turn has no live state (a finished historical turn). */
status?: 'running' | 'done' | 'error' | 'idle'
/** Live indicator text while thinking (the running step / tool / "thinking…"). */
label?: string | null
isLast?: boolean
planSteps?: PlanStep[]
taskStatus?: string
} = $props()
const TERMINAL = new Set(['done', 'failed', 'abandoned'])
// seq → step title (current-gen only) so tool groups can label themselves.
const stepTitle = $derived(new Map<number, string>(planSteps.map((s) => [s.seq, s.title])))
// Group consecutive tools by their plan step (when attributed). Plan-less /
// meta tools (propose_plan, set_goal, …) have no stepSeq and form orphan
// groups rendered without a header.
interface Group {
step: { seq: number; title: string } | null
tools: ToolCallResult[]
}
const groups = $derived.by<Group[]>(() => {
const out: Group[] = []
let cur: Group | null = null
for (const t of tools) {
const seq = t.stepSeq
if (!cur || (cur.step?.seq ?? null) !== (seq ?? null)) {
cur = {
step: seq != null && stepTitle.has(seq) ? { seq, title: stepTitle.get(seq)! } : null,
tools: []
}
out.push(cur)
}
cur.tools.push(t)
}
return out
})
// Thinking line: visible while the turn is running and the model is reasoning
// — before the first tool, or after a tool finishes but before the next one
// starts. Hidden while a tool is mid-flight (its own spinner carries the
// liveness) and on idle/finished turns.
const lastToolRunning = $derived(
tools.length > 0 && tools[tools.length - 1].type === 'tool_use'
)
const showThinking = $derived(status === 'running' && !lastToolRunning)
// Plan checklist: only on the running/last turn, and only if a plan exists.
const showPlan = $derived(isLast && planSteps.length > 0)
const planTerminal = $derived(!!taskStatus && TERMINAL.has(taskStatus))
let planExpanded = $state(false)
const planDone = $derived(planSteps.filter((s) => s.status === 'done').length)
const planFailedStep = $derived(planSteps.find((s) => s.status === 'failed'))
// Elapsed time on the running step — ticks every second while a step is
// running so the operator can see how long it's been going (and spot a
// stuck step).
let now = $state(Date.now())
$effect(() => {
const running = planSteps.some((s) => s.status === 'running')
if (!running) return
const id = setInterval(() => {
now = Date.now()
}, 1000)
return () => clearInterval(id)
})
function stepElapsed(s: PlanStep): string {
if (s.status !== 'running' || !s.started_at) return ''
const sec = Math.max(0, Math.floor((now - new Date(s.started_at).getTime()) / 1000))
if (sec < 60) return `${sec}s`
if (sec < 3600) return `${Math.floor(sec / 60)}m`
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`
}
</script>
{#if showPlan}
<div class="plan {planTerminal && !planExpanded ? 'plan-collapsed' : ''}">
{#if planTerminal && !planExpanded}
<button class="plan-summary" onclick={() => (planExpanded = true)}>
{#if planFailedStep}
<XIcon class="size-3 text-destructive" />
<span class="plan-summary-text">Plan failed — step {planFailedStep.seq}</span>
{:else}
<CheckIcon class="size-3 text-primary" />
<span class="plan-summary-text">Plan complete — {planDone}/{planSteps.length} steps</span>
{/if}
<ChevronRightIcon class="size-3 text-muted-foreground/60" />
</button>
{:else}
<div class="plan-head">
<span class="plan-head-label">Plan</span>
<span class="plan-head-count">{planDone}/{planSteps.length}</span>
</div>
<ul class="plan-list">
{#each planSteps as s (s.id)}
<li class="plan-step {s.status === 'running' ? 'running' : ''}">
<span class="plan-node {s.status}" aria-hidden="true">
{#if s.status === 'running'}<Spinner class="size-3 text-primary" />
{:else if s.status === 'done'}<CheckIcon class="size-2.5" strokeWidth={3.5} />
{:else if s.status === 'failed'}<XIcon class="size-2.5" strokeWidth={3.5} />
{:else if s.status === 'blocked'}<PauseIcon class="size-2" strokeWidth={3} />
{:else if s.status === 'skipped' || s.status === 'replaced'}<SlashIcon class="size-2" strokeWidth={3} />
{/if}
</span>
<span class="plan-title" title={s.title}>{s.title}</span>
{#if s.status === 'running'}
<span class="plan-elapsed">{stepElapsed(s)}</span>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
{/if}
<!-- Thinking line: always rendered (fixed height) so appearing/disappearing
doesn't shift the layout — it just fades in/out. Shows a STABLE label
("Working…") rather than the current operation, which would rewrite
itself on every step/tool transition and read as text appearing and
disappearing. The current operation is already visible in the plan
checklist (running step) and the tool lines below. -->
<div class="thinking {showThinking ? '' : 'thinking-hidden'}" aria-hidden={!showThinking}>
<Spinner class="size-3 shrink-0 text-primary" />
<span class="thinking-text">Working…</span>
</div>
{#if groups.length > 0}
<div class="tools">
{#each groups as g, gi (gi)}
{#if g.step}
<div class="step-head">Step {g.step.seq} · {g.step.title}</div>
{/if}
{#each g.tools as tool (tool.id ?? `${gi}-${tool.name}`)}
<ToolLine {tool} />
{/each}
{/each}
</div>
{:else if status === 'idle' && tools.length === 0}
<!-- finished turn with no tools: nothing to render -->
{:else if status === 'error'}
<div class="thinking err">
<XIcon class="size-3 shrink-0 text-destructive" />
<span class="thinking-text">{label || 'Turn ended with an error'}</span>
</div>
{/if}
<style>
.plan {
border: 1px solid var(--border);
background: color-mix(in oklab, var(--primary) 3%, var(--card));
padding: 0.35rem 0.55rem 0.4rem;
margin-bottom: 0.35rem;
animation: plan-in 0.2s ease-out;
}
@keyframes plan-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.plan {
animation: none;
}
}
.plan-summary {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
background: transparent;
border: none;
cursor: pointer;
padding: 0;
text-align: left;
}
.plan-summary-text {
font-size: 12px;
color: var(--foreground);
flex: 1;
}
.plan-head {
display: flex;
align-items: baseline;
gap: 0.4rem;
margin-bottom: 0.25rem;
}
.plan-head-label {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted-foreground);
}
.plan-head-count {
font-family: var(--font-mono);
font-size: 10px;
color: var(--primary);
}
.plan-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.plan-step {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.1rem 0;
}
.plan-step.running {
background: color-mix(in oklab, var(--primary) 8%, transparent);
margin: 0 -0.3rem;
padding-left: 0.3rem;
padding-right: 0.3rem;
}
.plan-node {
display: inline-flex;
align-items: center;
justify-content: center;
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
color: var(--muted-foreground);
}
.plan-node.done {
color: var(--primary);
}
.plan-node.failed {
color: var(--destructive);
}
.plan-node.running {
color: var(--primary);
}
.plan-title {
font-size: 12px;
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plan-step.running .plan-title {
color: var(--foreground);
font-weight: 500;
}
.plan-elapsed {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 10px;
color: var(--primary);
margin-left: auto;
}
.thinking {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0;
height: 1.5rem;
overflow: hidden;
opacity: 1;
transition: opacity 0.2s ease;
}
.thinking-hidden {
opacity: 0;
pointer-events: none;
}
.thinking.err {
color: var(--destructive);
}
.thinking-text {
font-size: 12px;
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tools {
display: flex;
flex-direction: column;
}
.step-head {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted-foreground);
padding: 0.35rem 0 0.1rem;
}
</style>

View File

@@ -0,0 +1,242 @@
<script lang="ts">
import { TableHandler } from '@vincjo/datatables'
import { Skeleton } from '$lib/components/ui/skeleton'
import SortHeader from './SortHeader.svelte'
import Toolbar from './Toolbar.svelte'
import Pagination from './pagination/Pagination.svelte'
import EmptyState from '$lib/components/EmptyState.svelte'
import BadgeRenderer from './renderers/BadgeRenderer.svelte'
import HealthDotRenderer from './renderers/HealthDotRenderer.svelte'
import RelativeTimeRenderer from './renderers/RelativeTimeRenderer.svelte'
import DateRenderer from './renderers/DateRenderer.svelte'
import StatusBadgeRenderer from './renderers/StatusBadgeRenderer.svelte'
import { resolveCellValue } from './columns'
import type { DataTableColumn, BuiltinRenderer } from './types'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Row = Record<string, any>
const renderers: Record<string, unknown> = {
badge: BadgeRenderer,
'health-dot': HealthDotRenderer,
'relative-time': RelativeTimeRenderer,
date: DateRenderer,
'status-badge': StatusBadgeRenderer
}
let {
columns,
data = [],
pageSize = 20,
paginated = false,
searchable = false,
bordered = true,
loading = false,
emptyMessage = 'No items.',
selected = $bindable(null),
onRowClick = undefined,
class: className,
children
}: {
columns: DataTableColumn<Row>[]
data: Row[]
pageSize?: number
paginated?: boolean
searchable?: boolean
bordered?: boolean
loading?: boolean
emptyMessage?: string
selected?: string | null
onRowClick?: (row: Row) => void
class?: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children?: any
} = $props()
const table = new TableHandler([], { pageSize: 20 })
// One SortBuilder per sortable column — each tracks its own direction/isActive
// via $derived runes internally.
const sortBuilders = new Map<string, ReturnType<typeof table.createSort>>()
function getSortBuilder(col: DataTableColumn<Row>) {
if (!sortBuilders.has(col.key)) {
sortBuilders.set(col.key, table.createSort(col.accessor ?? col.key))
}
return sortBuilders.get(col.key)!
}
let search = $state.raw(
table.createSearch({
filterFunction: (row: Row, q: string) => {
if (!q) return true
const lower = q.toLowerCase()
for (const col of columns) {
if (col.hidden) continue
const val = String(resolveCellValue(row, col) ?? '').toLowerCase()
if (val.includes(lower)) return true
}
return false
}
})
)
$effect(() => {
table.setRowsPerPage(pageSize)
})
$effect(() => {
table.setRows(data)
})
function handleSearch(q: string) {
search.set(q)
if (paginated) table.setPage(1)
}
function colAlignClass(col: DataTableColumn<Row>): string {
if (col.align === 'right') return 'text-right'
if (col.align === 'center') return 'text-center'
return ''
}
function colTruncateClass(col: DataTableColumn<Row>): string {
return col.truncate ? 'min-w-0 overflow-hidden text-ellipsis' : ''
}
function colStyle(col: DataTableColumn<Row>): string | undefined {
if (!col.width) return undefined
const w = typeof col.width === 'number' ? col.width + 'px' : col.width
return `width: ${w}; min-width: ${w}`
}
const visibleCols = $derived(columns.filter((c) => !c.hidden))
const rows = $derived(table.rows as Row[])
const skeletonWidths = ['w-24', 'w-20', 'w-28', 'w-16', 'w-24', 'w-20', 'w-28', 'w-16']
</script>
<div class={['flex flex-col h-full min-h-0', className].filter(Boolean).join(' ')}>
<Toolbar {table} {searchable} {paginated} onSearchChange={handleSearch} {children} />
<div
class={['flex flex-col min-h-0 flex-1', bordered ? 'rounded-xl border' : '']
.filter(Boolean)
.join(' ')}
>
<table class="w-full caption-bottom text-sm table-fixed">
<thead class="[&_tr]:border-b">
<tr>
{#each visibleCols as col (col.key)}
<th
class={[
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap',
'bg-card/95',
col.headerClass,
colAlignClass(col)
]
.filter(Boolean)
.join(' ')}
style={colStyle(col)}
>
{#if col.sortable !== false}
{@const sb = getSortBuilder(col)}
<SortHeader
label={col.header}
sorted={sb.isActive}
direction={sb.direction ?? 'asc'}
onclick={() => sb.set()}
/>
{:else}
{col.header}
{/if}
</th>
{/each}
</tr>
</thead>
</table>
<div class="min-h-0 flex-1 overflow-y-auto">
<table class="w-full caption-bottom text-sm table-fixed">
<tbody class="[&_tr:last-child]:border-0">
{#if loading}
{#each skeletonWidths as w, i}
<tr class="border-b transition-colors hover:bg-transparent">
{#each visibleCols as col (col.key)}
<td
class={[col.class, colAlignClass(col), colTruncateClass(col)]
.filter(Boolean)
.join(' ')}
style={colStyle(col)}
>
<Skeleton
class="h-4 {skeletonWidths[
(i + visibleCols.indexOf(col)) % skeletonWidths.length
]}"
/>
</td>
{/each}
</tr>
{/each}
{:else if rows.length === 0}
<EmptyState message={emptyMessage} colspan={visibleCols.length} />
{:else}
{#each rows as row, idx (row.id ?? row.slug ?? `row-${idx}`)}
<tr
class={[
'border-b transition-colors hover:bg-muted/50',
onRowClick ? 'cursor-pointer' : '',
selected === (row.id ?? row.slug) ? 'bg-muted' : ''
]
.filter(Boolean)
.join(' ')}
tabindex={onRowClick ? 0 : undefined}
onclick={onRowClick ? () => onRowClick(row) : undefined}
onkeydown={onRowClick
? (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onRowClick(row)
}
}
: undefined}
>
{#each visibleCols as col (col.key)}
{@const val = resolveCellValue(row, col)}
<td
class={[
'p-2 align-middle whitespace-nowrap',
col.class,
colAlignClass(col),
colTruncateClass(col)
]
.filter(Boolean)
.join(' ')}
style={colStyle(col)}
>
{#if typeof col.render === 'string'}
{@const R = renderers[col.render]}
{#if R}
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
<R value={val} {row} {...col.renderProps ?? {}} />
{:else}
{String(val ?? '—')}
{/if}
{:else if typeof col.render === 'function'}
<col.render {row} value={val} {...col.renderProps ?? {}} />
{:else}
{String(val ?? '—')}
{/if}
</td>
{/each}
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
{#if paginated}
<Pagination {table} />
{/if}
</div>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input'
import SearchIcon from '@lucide/svelte/icons/search'
import XIcon from '@lucide/svelte/icons/x'
import { debounce } from '$lib/utils'
let {
value = '',
placeholder = 'Search...',
class: className,
onSearch
}: {
value?: string
placeholder?: string
class?: string
onSearch?: (q: string) => void
} = $props()
let inputVal = $state('')
const debouncedSearch = debounce((q: string) => {
onSearch?.(q)
}, 200)
function handleInput(e: Event) {
const target = e.target as HTMLInputElement
inputVal = target.value
debouncedSearch(inputVal)
}
function clear() {
inputVal = ''
onSearch?.('')
}
</script>
<div class={['relative', className].filter(Boolean).join(' ')}>
<SearchIcon class="absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
{placeholder}
value={inputVal}
oninput={handleInput}
class="h-8 pl-8 pr-8 text-xs"
/>
{#if inputVal}
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onclick={clear}
>
<XIcon class="size-3" />
</button>
{/if}
</div>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down'
let {
label,
sorted = false,
direction = 'asc',
onclick
}: {
label: string
sorted?: boolean
direction?: 'asc' | 'desc'
onclick?: () => void
} = $props()
</script>
<button type="button" class="flex items-center gap-1 hover:text-foreground" {onclick}>
{label}
{#if sorted}
{#if direction === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
{:else}
<ArrowUpDownIcon class="size-3 text-muted-foreground/50" />
{/if}
</button>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import SearchInput from './SearchInput.svelte'
import RowsPerPage from './pagination/RowsPerPage.svelte'
import type { TableHandler } from '@vincjo/datatables'
let {
table,
searchable = false,
paginated = false,
onSearchChange,
children
}: {
table: TableHandler<Record<string, unknown>>
searchable?: boolean
paginated?: boolean
onSearchChange?: (q: string) => void
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children?: any
} = $props()
</script>
{#if searchable || paginated || children}
<div class="flex items-center gap-2 px-1 py-2">
{#if searchable}
<SearchInput placeholder="Search..." onSearch={onSearchChange} class="w-64" />
{/if}
<div class="flex-1"></div>
{@render children?.()}
{#if paginated}
<RowsPerPage {table} />
{/if}
</div>
{/if}

View File

@@ -0,0 +1,9 @@
import type { DataTableColumn } from './types'
export function resolveCellValue<T>(row: T, col: DataTableColumn<T>): unknown {
if (col.accessor) return col.accessor(row)
if (col.key in (row as Record<string, unknown>)) {
return (row as Record<string, unknown>)[col.key]
}
return undefined
}

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import type { ButtonSize } from '$lib/components/ui/button'
let {
page,
active,
disabled = false,
size = 'xs' as ButtonSize,
onclick
}: {
page: number | string
active?: boolean
disabled?: boolean
size?: ButtonSize
onclick?: () => void
} = $props()
</script>
<Button {size} variant={active ? 'default' : 'outline'} {disabled} {onclick}>
{String(page)}
</Button>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import PageButton from './PageButton.svelte'
import type { TableHandler } from '@vincjo/datatables'
let { table }: { table: TableHandler<Record<string, unknown>> } = $props()
const pages = $derived(table.pagesWithEllipsis as (number | '...')[])
const currentPage = $derived(table.currentPage)
const pageCount = $derived(table.pageCount)
const rowCount = $derived(table.rowCount)
</script>
{#if pageCount > 1}
<div class="flex items-center justify-between gap-2 px-2 py-1.5">
<span class="text-xs text-muted-foreground">{rowCount} rows</span>
<div class="flex items-center gap-1">
<PageButton
page={ChevronLeftIcon}
disabled={currentPage === 1}
onclick={() => table.setPage('previous')}
/>
{#each pages as page}
{#if page === '...'}
<span class="px-1 text-xs text-muted-foreground"></span>
{:else}
<PageButton
{page}
active={page === currentPage}
onclick={() => table.setPage(page as number)}
/>
{/if}
{/each}
<PageButton
page={ChevronRightIcon}
disabled={currentPage === pageCount}
onclick={() => table.setPage('next')}
/>
</div>
</div>
{/if}

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select'
import type { TableHandler } from '@vincjo/datatables'
let {
table,
class: className
}: { table: TableHandler<Record<string, unknown>>; class?: string } = $props()
const options = [10, 20, 50, 100]
let value = $state('20')
function handleChange(newValue: string | undefined) {
if (!newValue) return
value = newValue
table.setRowsPerPage(parseInt(newValue))
}
</script>
<Select.Root type="single" {value} onValueChange={handleChange}>
<Select.Trigger size="sm" class={className}>
{value}
</Select.Trigger>
<Select.Content>
{#each options as n}
<Select.Item value={String(n)}>{n} / page</Select.Item>
{/each}
</Select.Content>
</Select.Root>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { ActivityItem } from '$lib/api'
let { row }: { row: ActivityItem } = $props()
function fmtDuration(ms: number | null): string {
if (ms == null) return '—'
if (ms < 1000) return `${ms}ms`
const s = Math.round(ms / 1000)
if (s < 60) return `${s}s`
return `${Math.floor(s / 60)}m ${s % 60}s`
}
</script>
<div>
<div>{row.verb}</div>
{#if row.summary}
<div class="text-xs text-muted-foreground">{row.summary}</div>
{/if}
{#if row.error}
<div class="text-xs text-destructive">{row.error}</div>
{/if}
</div>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import type { ActivityItem } from '$lib/api'
let {
row,
onCancel
}: {
row: ActivityItem
onCancel?: (id: string) => void
} = $props()
function showCancel(status: string): boolean {
return ['pending_approval', 'approved', 'running'].includes(status)
}
</script>
<div class="flex justify-end">
{#if showCancel(row.status)}
<Button size="sm" variant="outline" onclick={() => onCancel?.(row.id)}>Cancel</Button>
{/if}
</div>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import type { Approval } from '$lib/api'
let {
row,
deciding = null,
onApprove,
onDeny
}: {
row: Approval
deciding?: string | null
onApprove?: (id: string) => void
onDeny?: (id: string) => void
} = $props()
</script>
<div class="flex justify-end gap-2">
<Button size="sm" disabled={deciding === row.id} onclick={() => onApprove?.(row.id)}
>Approve</Button
>
<Button
size="sm"
variant="destructive"
disabled={deciding === row.id}
onclick={() => onDeny?.(row.id)}>Deny</Button
>
</div>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { Badge, type BadgeVariant } from '$lib/components/ui/badge'
let { value, variant = 'outline' as BadgeVariant }: { value: unknown; variant?: BadgeVariant } =
$props()
</script>
<Badge {variant}>{String(value ?? '—')}</Badge>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
let { value }: { value: unknown } = $props()
function format(val: unknown): string {
if (!val) return '—'
try {
return new Date(String(val)).toLocaleString()
} catch {
return String(val)
}
}
</script>
<span class="text-xs text-muted-foreground">{format(value)}</span>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { ActivityItem } from '$lib/api'
let { value }: { value: unknown } = $props()
function fmtDuration(ms: number | null): string {
if (ms == null) return '—'
if (ms < 1000) return `${ms}ms`
const s = Math.round(ms / 1000)
if (s < 60) return `${s}s`
return `${Math.floor(s / 60)}m ${s % 60}s`
}
</script>
<span class="text-xs text-muted-foreground">{fmtDuration(value as number | null)}</span>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { relativeTime } from '$lib/utils'
import type { Entity } from '$lib/api'
let { row, value }: { row: Entity; value: unknown } = $props()
const dot: Record<string, string> = {
healthy: 'bg-success',
degraded: 'bg-warning',
down: 'bg-destructive',
stale: 'bg-warning/50',
unknown: 'bg-muted-foreground/40'
}
const health = $derived(row.health)
const lastCheck = $derived(row.last_check_at)
const title = $derived.by(() => {
if (!row.health) return 'not monitored'
if (row.health === 'stale') return `stale — last checked ${relativeTime(row.last_check_at)}`
return `${row.health} — checked ${relativeTime(row.last_check_at)}`
})
</script>
{#if health}
<span class="flex items-center gap-1.5 text-xs" {title}>
<span class="size-2 shrink-0 rounded-full {dot[row.health ?? ''] ?? ''}"></span>
<span class="text-muted-foreground">{relativeTime(lastCheck)}</span>
</span>
{:else}
<span class="text-xs text-muted-foreground"></span>
{/if}

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { relativeTime } from '$lib/utils'
let { value }: { value: unknown } = $props()
</script>
<span class="text-xs text-muted-foreground">{relativeTime(String(value ?? ''))}</span>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import type { Signal } from '$lib/api'
let {
row,
acting = null,
onAck,
onMute,
onResolve
}: {
row: Signal
acting?: string | null
onAck?: (id: string) => void
onMute?: (id: string) => void
onResolve?: (id: string) => void
} = $props()
</script>
<div class="flex justify-end gap-2">
{#if row.state === 'raised'}
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onAck?.(row.id)}
>Ack</Button
>
{/if}
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onMute?.(row.id)}
>Mute 1h</Button
>
<Button size="sm" disabled={acting === row.id} onclick={() => onResolve?.(row.id)}>Resolve</Button
>
</div>

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge'
let {
value,
kind = 'default'
}: { value: unknown; kind?: 'risk' | 'severity' | 'execution' | 'state' | 'type' | 'default' } =
$props()
const v = $derived(String(value ?? ''))
const variantMap: Record<
string,
Record<string, 'default' | 'secondary' | 'destructive' | 'outline'>
> = {
risk: {
destructive: 'destructive',
config_mutation: 'secondary',
default: 'default'
},
severity: {
critical: 'destructive',
warning: 'secondary',
info: 'default',
default: 'default'
},
execution: {
failed: 'destructive',
denied: 'destructive',
revoked: 'destructive',
cancelled: 'destructive',
completed: 'default',
running: 'secondary',
approved: 'secondary',
default: 'outline'
},
state: {
active: 'default',
healthy: 'default',
default: 'outline'
},
type: {
runbook: 'secondary',
investigation: 'default',
default: 'outline'
},
default: { default: 'default' }
}
const variant = $derived.by(() => {
const map = variantMap[kind] ?? variantMap.default
return (map[v] ?? map.default) as 'default' | 'secondary' | 'destructive' | 'outline'
})
</script>
<Badge {variant}>{v}</Badge>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import { statusStyle } from '$lib/tasks'
import type { Session } from '$lib/api'
let { row }: { row: Session } = $props()
const st = $derived(statusStyle(row))
</script>
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
{st.label}
</span>

View File

@@ -0,0 +1,36 @@
import type { ComponentType, SvelteComponent } from 'svelte'
export type BuiltinRenderer = 'badge' | 'health-dot' | 'relative-time' | 'date' | 'status-badge'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type CellComponent = ComponentType<SvelteComponent<{ row: any; value: unknown }>>
export interface DataTableColumn<T> {
key: string
header: string
sortable?: boolean
width?: string | number
align?: 'left' | 'right' | 'center'
truncate?: boolean
class?: string
headerClass?: string
render?: BuiltinRenderer | CellComponent
renderProps?: Record<string, unknown>
accessor?: (row: T) => unknown
hidden?: boolean
}
export interface DataTableProps<T> {
columns: DataTableColumn<T>[]
data: T[]
pageSize?: number
paginated?: boolean
searchable?: boolean
loading?: boolean
emptyMessage?: string
selected?: string[]
onRowClick?: (row: T) => void
class?: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children?: any
}

View File

@@ -0,0 +1,152 @@
<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 { getBackground } from '$lib/stores/background.svelte'
import { patternCss } from '$lib/desktop-patterns'
import DesktopIcon from './DesktopIcon.svelte'
import TaskLauncher from './TaskLauncher.svelte'
import WindowLayer from './WindowLayer.svelte'
import DockedLayer from './DockedLayer.svelte'
import Taskbar from './Taskbar.svelte'
import * as ContextMenu from '$lib/components/ui/context-menu'
import LayersIcon from '@lucide/svelte/icons/layers'
import Rows3Icon from '@lucide/svelte/icons/rows-3'
import MonitorIcon from '@lucide/svelte/icons/monitor'
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
import Undo2Icon from '@lucide/svelte/icons/undo-2'
import Redo2Icon from '@lucide/svelte/icons/redo-2'
// canUndo/canRedo are plain wmkit method calls (not stores), so they're
// snapshotted once when the menu opens (onOpenChange) rather than read
// reactively in the template. bits-ui auto-dismisses on item select and
// on Escape / click-away, so the old manual menuPos/closeMenu/runMenuAction
// machinery is gone.
let menuCanUndo = $state(false)
let menuCanRedo = $state(false)
function onOpenChange(open: boolean) {
if (!open) return
menuCanUndo = wm.canUndo()
menuCanRedo = wm.canRedo()
}
// 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) {
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()
}
// Configurable in Settings → Appearance (see background.svelte.ts). Two
// layers, not one, because rotation and the fade mask need different
// geometry:
// - outer: exactly the viewport box. Carries the fade mask, since a
// vignette has to be centered on what's actually visible.
// - inner: oversized (200%) and centered before rotating, so turning the
// pattern doesn't pull its straight edges into view at the corners —
// a viewport-sized box rotated in place would do exactly that.
const bgActive = $derived.by(() => {
const bg = getBackground()
return bg.pattern !== 'none' || bg.fillColor !== null
})
const bgOuterStyle = $derived.by(() => {
const bg = getBackground()
if (bg.fade <= 0) return ''
const stop = Math.round(100 - bg.fade * 70)
const mask = `radial-gradient(circle at 50% 50%, black 0%, black ${stop}%, transparent 100%)`
return `mask-image:${mask};-webkit-mask-image:${mask};`
})
const bgInnerStyle = $derived.by(() => {
const bg = getBackground()
const css = patternCss(bg.pattern, bg.color, bg.scale)
return `inset:-50%;width:200%;height:200%;opacity:${bg.opacity};background-color:${bg.fillColor ?? 'transparent'};transform:rotate(${bg.rotation}deg);${css}`
})
</script>
<svelte:window onkeydown={onWindowKeydown} />
<div class="fixed inset-0 flex flex-col">
<div class="relative min-h-0 flex-1 overflow-hidden" role="presentation">
{#if bgActive}
<div
class="pointer-events-none absolute inset-0 z-0 overflow-hidden"
aria-hidden="true"
style={bgOuterStyle}
>
<div class="absolute" style={bgInnerStyle}></div>
</div>
{/if}
<ContextMenu.Root {onOpenChange}>
<!-- The bare-desktop hit area. Placed before the icons/windows layers
so they (pointer-events-auto, later in DOM → paint on top) catch
their own right-clicks — the trigger only sees right-clicks that
fall through to bare desktop. This DOM-structure gate replaces the
old `currentTarget === target` event check. Left-click on bare
desktop blurs the focused window (the familiar "click empty
desktop to deselect" affordance). -->
<ContextMenu.Trigger class="absolute inset-0 z-0" onclick={() => wm.blur()}
></ContextMenu.Trigger>
<ContextMenu.Content class="min-w-48">
<ContextMenu.Item onSelect={() => wm.arrange('cascade')}>
<LayersIcon class="size-4" /> Cascade windows
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => wm.arrange('tile')}>
<Rows3Icon class="size-4" /> Tile windows
</ContextMenu.Item>
<ContextMenu.Item onSelect={toggleShowDesktop}>
<MonitorIcon class="size-4" /> Show desktop
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item onSelect={resetIconLayout}>
<RotateCcwIcon class="size-4" /> Reset icon layout
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item disabled={!menuCanUndo} onSelect={() => wm.undo()}>
<Undo2Icon class="size-4" /> Undo
</ContextMenu.Item>
<ContextMenu.Item disabled={!menuCanRedo} onSelect={() => wm.redo()}>
<Redo2Icon class="size-4" /> Redo
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
<div class="pointer-events-none absolute inset-0 z-0">
{#each $apps as app (app.id)}
{@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 />
<DockedLayer />
</div>
<Taskbar />
</div>

View File

@@ -0,0 +1,109 @@
<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.5 p-1.5 pointer-events-auto select-none transition-transform focus-visible:outline-2 focus-visible:outline-ring {dragging
? 'z-50 cursor-grabbing'
: 'cursor-pointer hover:-translate-y-0.5'}"
style="left: {left}px; top: {top}px; width: {GRID.cell}px;"
onpointerdown={onPointerDown}
onkeydown={onKeydown}
title={app.title}
>
<span
class="relative flex size-11 items-center justify-center border transition-all {dragging
? 'border-foreground bg-foreground text-background shadow-[4px_4px_0_0_var(--foreground)]'
: 'border-border bg-card text-foreground group-hover:border-foreground group-hover:bg-foreground group-hover:text-background'}"
>
<app.icon class="size-5 transition-colors" />
{#if badge > 0}
<span
class="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center border border-background 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-[10px] uppercase tracking-wider text-muted-foreground transition-colors group-hover:text-foreground"
>{app.title}</span
>
</button>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
// The docked-app layer: renders apps flagged `docked: true` on a
// pointer-events-none absolute inset-0 overlay above WindowLayer's z-40,
// below the desktop context menu's z-50. Docked apps have no wmkit window,
// no titlebar, and no taskbar button; their visibility is toggled by
// clicking their desktop icon (see stores/docked.ts). Replaces the
// previously-hardcoded <MascotLayer /> in Desktop.svelte — the mascot is
// now the first docked app, not a shell special case. Rendered as a sibling
// inside the surface div so docked apps share the surface's coordinate
// space (the mascot's ground-line computation depends on this).
import { apps } from '$lib/apps'
import { dockedVisibility } from '$lib/stores/docked'
import LazyApp from '$lib/components/desktop-shell/LazyApp.svelte'
const dockedApps = $derived($apps.filter((a) => a.docked))
</script>
<div class="pointer-events-none absolute inset-0 z-45">
{#each dockedApps as app (app.id)}
{#if $dockedVisibility[app.id] ?? true}
<LazyApp load={app.component} />
{/if}
{/each}
</div>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
// Renders an App's lazily-loaded component (AppDef.component is a
// dynamic-import loader, not the component itself). Shows the shared
// spinner while the chunk fetches; Vite's module cache makes repeat
// opens resolve from cache on the next microtask, so the spinner is
// one-tick at most after first load. Used by both WindowLayer
// (windowed apps) and DockedLayer (docked apps) so the loading state
// is uniform across app kinds.
import type { Component } from 'svelte'
import { untrack } from 'svelte'
import Spinner from '../Spinner.svelte'
let { load }: { load: () => Promise<{ default: Component }> } = $props()
// Created once per mount, not per render. `load` is the app's stable
// registry loader (app.component — defined once in the APPS array, never
// reassigned), so reading it at init is correct; untrack tells Svelte the
// one-shot read is intentional and silences the state_referenced_locally
// lint. Without pinning, {#await} would re-subscribe to a fresh Promise on
// every reactive re-evaluation of load() and loop.
const promise = untrack(() => load())
</script>
{#await promise}
<div class="flex h-full min-h-0 items-center justify-center text-muted-foreground">
<Spinner class="size-5" />
</div>
{:then mod}
{@const C = mod.default}
<C />
{:catch error}
<div
class="flex h-full min-h-0 items-center justify-center p-4 text-center text-sm text-destructive"
>
Failed to load app: {(error as Error).message}
</div>
{/await}

View File

@@ -0,0 +1,35 @@
<script lang="ts">
// Content for the "new-task" window slot (windows.ts openNewTaskWindow) —
// a real ChatThread in its empty state rather than a separate compose
// screen, so starting a task looks and feels exactly like the task chat
// it becomes. Submitting the first message starts the task via
// startTask() and hands off to the real session window (see windows.ts's
// openTaskWindow) the moment the backend assigns an id.
import { startTask } from '$lib/stores/chat'
import { openTaskWindow, wm, NEW_TASK_WINDOW_ID } from '$lib/stores/windows'
import { truncateMiddle } from '$lib/utils'
import ChatThread from '$lib/components/ChatThread.svelte'
// Seeded by openNewTaskWindow when a caller (the entity window's "Ask Nomos")
// knows what the task is about.
let { initialDraft = '' }: { initialDraft?: string } = $props()
function onSend(text: string) {
startTask(text, (sessionId) => {
openTaskWindow(sessionId, truncateMiddle(text, 60))
wm.close(NEW_TASK_WINDOW_ID)
})
}
</script>
<ChatThread
{initialDraft}
messages={[]}
streaming={false}
working={false}
connectionState="connected"
{onSend}
onCancel={() => {}}
onReconnect={() => {}}
onDismissError={() => {}}
/>

View File

@@ -0,0 +1,63 @@
<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 field-sizing-fixed border-0 bg-transparent px-4 py-3.5 text-base shadow-none focus-visible:ring-0"
/>
<div class="flex items-center justify-between px-3 pb-3">
<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,133 @@
<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 border-border bg-background px-2">
<button
type="button"
class="flex shrink-0 items-center justify-center border border-border p-1.5 text-muted-foreground transition-colors hover:border-foreground hover:bg-foreground hover:text-background"
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 py-1.5">
{#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 border px-2 font-mono text-xs transition-colors {$wmState.focusedId ===
win.id && win.stage !== 'minimized'
? 'border-foreground bg-foreground text-background'
: 'border-border bg-background text-muted-foreground hover:border-foreground hover:bg-foreground hover:text-background'} {win.stage ===
'minimized'
? 'opacity-50'
: ''}"
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 border border-background bg-destructive px-1 text-[9px] font-semibold text-destructive-foreground"
>
{badge > 99 ? '99+' : badge}
</span>
{/if}
</button>
<button
type="button"
class="win-ctrl absolute -top-1.5 -right-1.5 hidden size-4 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 border border-border p-1.5 text-muted-foreground transition-colors hover:border-foreground hover:bg-foreground hover:text-background"
onclick={() => toggleTheme()}
title="Cycle theme ({THEME_LABELS[getTheme()]})"
aria-label="Cycle theme, currently {THEME_LABELS[getTheme()]}"
>
<PaletteIcon class="size-4" />
</button>
<button
type="button"
class="flex items-center justify-center border border-border p-1.5 text-muted-foreground transition-colors hover:border-foreground hover:bg-foreground hover:text-background"
onclick={() => openAppWindow('settings')}
title="Settings"
>
<SettingsIcon class="size-4" />
</button>
<span class="px-1.5 font-mono text-[11px] text-muted-foreground select-none">{VERSION}</span>
</div>
</div>

View File

@@ -0,0 +1,121 @@
<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 -> NewTaskChat (windows.ts openNewTaskWindow)
// anything else -> entity slug -> EntityDetailContent
import {
wm,
dk,
wmState,
windowKeys,
openEntityWindow,
NEW_TASK_WINDOW_ID,
SESSION_PREFIX,
takePendingTaskDraft
} from '$lib/stores/windows'
import { appById, appIdFromWindowId } from '$lib/apps'
import EntityDetailContent from '../EntityDetailContent.svelte'
import SessionChatWindow from '../SessionChatWindow.svelte'
import NewTaskChat from './NewTaskChat.svelte'
import LazyApp from '$lib/components/desktop-shell/LazyApp.svelte'
import XIcon from '@lucide/svelte/icons/x'
import MinusIcon from '@lucide/svelte/icons/minus'
import Maximize2Icon from '@lucide/svelte/icons/maximize-2'
// A hydrated `app:<id>` window whose id no longer matches any registry
// entry (the app was uninstalled/removed since the layout was persisted)
// has nothing to render — close it rather than leaving a permanently-blank
// window stuck in the taskbar. Reactive on `appById` so reinstalling an
// app revives its persisted window on the next tick rather than requiring
// a reload, and uninstalling closes its orphan window immediately.
$effect(() => {
const idx = $appById
for (const id of $wmState.order) {
const appId = appIdFromWindowId(id)
if (appId && !idx.has(appId)) wm.close(id)
}
})
// Keep an open app window's title in sync with its registry entry. The
// title is copied into the window at open time and then persisted, so a
// rename (e.g. "Knowledge Base" -> "Fleet") would otherwise stay stuck in
// the titlebar/taskbar of any already-open or hydrated window until it was
// closed and reopened. Mirrors the task-window title sync in windows.ts.
$effect(() => {
const idx = $appById
for (const id of $wmState.order) {
const appId = appIdFromWindowId(id)
const app = appId ? idx.get(appId) : undefined
if (app && $wmState.windows[id]?.title !== app.title) {
wm.update(id, { title: app.title })
}
}
})
</script>
<div use:dk.desktop class="absolute inset-0 z-40 pointer-events-none">
{#each $windowKeys 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 h-9 shrink-0 cursor-move items-center justify-between gap-2 overflow-hidden border-b border-border bg-background px-3"
>
<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-1">
<button
type="button"
data-wm-minimize
class="win-ctrl flex size-6"
aria-label="Minimize {win.title}"
>
<MinusIcon class="size-3.5" />
</button>
<button
type="button"
data-wm-maximize
class="win-ctrl flex size-6"
aria-label="Maximize {win.title}"
>
<Maximize2Icon class="size-3.5" />
</button>
<button
type="button"
data-wm-close
class="win-ctrl flex size-6"
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}
<NewTaskChat initialDraft={takePendingTaskDraft()} />
{:else if app}
<LazyApp load={app.component} />
{:else}
<EntityDetailContent slug={id} onSelectEntity={openEntityWindow} />
{/if}
{/key}
</div>
</section>
{/if}
{/each}
</div>

View File

@@ -0,0 +1,626 @@
<script lang="ts">
// Maintenance view for the knowledge base's own drift — duplicates, tag
// casing splits, orphaned notes, and the trash. Surfaced as its own mode
// rather than folded into the main three-pane view because none of this
// is "browse a note," it's "audit the collection," and mixing the two
// would clutter the read/edit flow with tools most visits don't need.
//
// Every action here (merge, rename, restore) is deliberately one click
// away from a review step, never automatic — see fetchKnowledgeDuplicates'
// own doc comment on why title-similarity clustering can't be trusted as
// a verdict (the five "Lifecycle: <verb> a node" runbooks cluster despite
// being genuinely distinct documents).
import {
fetchKnowledgeDuplicates,
fetchKnowledgeTags,
fetchKnowledgeOrphans,
fetchKnowledgeTrash,
renameKnowledgeTag,
mergeKnowledge,
restoreKnowledge,
KnowledgeApiError,
type KnowledgeDuplicateCluster,
type KnowledgeTag,
type KnowledgeOrphan,
type KnowledgeTrashItem
} from '$lib/api'
import * as Tabs from '$lib/components/ui/tabs'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import { Badge } from '$lib/components/ui/badge'
import { Skeleton } from '$lib/components/ui/skeleton'
import { toast } from 'svelte-sonner'
import { kindMeta } from './kinds'
import { relativeTime } from '$lib/utils'
import CopyIcon from '@lucide/svelte/icons/copy'
import TagIcon from '@lucide/svelte/icons/tag'
import GhostIcon from '@lucide/svelte/icons/ghost'
import Trash2Icon from '@lucide/svelte/icons/trash-2'
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'
import CheckIcon from '@lucide/svelte/icons/check'
let {
onSelect,
onChanged
}: {
onSelect: (slug: string) => void
// Fired after any mutation (merge, tag rename, restore) so the parent's
// note list — which this view reads a filtered copy of, indirectly —
// stays in sync.
onChanged: () => void
} = $props()
let tab = $state<'duplicates' | 'tags' | 'orphans' | 'trash'>('duplicates')
// Shared across every loader/action below: the read helpers in api.ts now
// throw KnowledgeApiError on a failed request instead of quietly returning
// an empty list, so a real outage can't be mistaken for "nothing to
// clean up" — see api.ts's comment on listKnowledge for the same fix
// applied to the main note list.
function errMsg(e: unknown): string {
return e instanceof KnowledgeApiError ? e.message : 'Request failed.'
}
// ─── Duplicates ───────────────────────────────────────────────────────
let clusters = $state<KnowledgeDuplicateCluster[] | null>(null)
let duplicatesError = $state('')
// Per cluster (indexed by the cluster's first member slug — stable across
// a reload since clusters are keyed by content, not array position):
// which slug is the merge target and which sources are checked.
let mergeTarget = $state<Record<string, string>>({})
let mergeSources = $state<Record<string, Set<string>>>({})
let merging = $state<string | null>(null)
let mergeError = $state('')
async function loadDuplicates(): Promise<void> {
clusters = null
duplicatesError = ''
try {
const result = await fetchKnowledgeDuplicates()
clusters = result
const targets: Record<string, string> = {}
const sources: Record<string, Set<string>> = {}
for (const c of result) {
const key = c.members[0].slug
targets[key] = c.members[0].slug // newest first — see the Go handler's sort
sources[key] = new Set(c.members.slice(1).map((m) => m.slug))
}
mergeTarget = targets
mergeSources = sources
} catch (e) {
duplicatesError = errMsg(e)
clusters = [] // clear the loading skeleton — the error message above explains the empty state
}
}
// A merge target switch leaves the PREVIOUS target unchecked (it's not in
// `sources` since it used to be excluded as "the target"), so recompute
// the whole source set relative to the new target rather than leaving it
// stale — otherwise the old target silently drops out of the merge
// instead of folding in like every other member.
function setMergeTarget(clusterKey: string, newTarget: string, allSlugs: string[]): void {
mergeTarget = { ...mergeTarget, [clusterKey]: newTarget }
mergeSources = {
...mergeSources,
[clusterKey]: new Set(allSlugs.filter((s) => s !== newTarget))
}
}
function toggleSource(clusterKey: string, slug: string): void {
const set = new Set(mergeSources[clusterKey])
if (set.has(slug)) set.delete(slug)
else set.add(slug)
mergeSources = { ...mergeSources, [clusterKey]: set }
}
async function doMerge(clusterKey: string): Promise<void> {
const target = mergeTarget[clusterKey]
const sources = [...(mergeSources[clusterKey] ?? [])]
if (!target || sources.length === 0) return
merging = clusterKey
mergeError = ''
try {
const result = await mergeKnowledge(target, sources)
toast.success(`Merged ${result.merged.length} note${result.merged.length === 1 ? '' : 's'}`)
onChanged()
await loadDuplicates()
} catch (e) {
mergeError = errMsg(e)
} finally {
merging = null
}
}
// ─── Tags ─────────────────────────────────────────────────────────────
let tags = $state<KnowledgeTag[] | null>(null)
let tagsError = $state('')
let renaming = $state<string | null>(null)
let renameDraft = $state('')
let renameBusy = $state(false)
async function loadTags(): Promise<void> {
tags = null
tagsError = ''
try {
tags = await fetchKnowledgeTags()
} catch (e) {
tagsError = errMsg(e)
tags = []
}
}
async function normalize(t: KnowledgeTag): Promise<void> {
renameBusy = true
try {
const n = await renameKnowledgeTag(t.variants, t.tag)
toast.success(`Normalized "${t.tag}" across ${n} note${n === 1 ? '' : 's'}`)
onChanged()
await loadTags()
} catch (e) {
toast.error(errMsg(e))
} finally {
renameBusy = false
}
}
function startRename(t: KnowledgeTag): void {
renaming = t.tag
renameDraft = t.tag
}
async function confirmRename(t: KnowledgeTag): Promise<void> {
const to = renameDraft.trim().toLowerCase()
if (!to || to === t.tag) {
renaming = null
return
}
renameBusy = true
try {
const n = await renameKnowledgeTag(t.variants, to)
toast.success(`Renamed "${t.tag}" to "${to}" across ${n} note${n === 1 ? '' : 's'}`)
onChanged()
await loadTags()
renaming = null
} catch (e) {
// Leave the rename input open on failure — the operator's typed value
// (and their reason for changing it) shouldn't vanish along with the
// error, forcing them to retype it to try again.
toast.error(errMsg(e))
} finally {
renameBusy = false
}
}
// ─── Orphans ──────────────────────────────────────────────────────────
let orphans = $state<KnowledgeOrphan[] | null>(null)
let orphanCounts = $state<Record<string, number>>({})
let orphansError = $state('')
async function loadOrphans(): Promise<void> {
orphans = null
orphansError = ''
try {
const result = await fetchKnowledgeOrphans()
orphans = result.items
orphanCounts = result.counts
} catch (e) {
orphansError = errMsg(e)
orphans = []
}
}
// ─── Trash ────────────────────────────────────────────────────────────
let trash = $state<KnowledgeTrashItem[] | null>(null)
let trashError = $state('')
let restoring = $state<string | null>(null)
async function loadTrash(): Promise<void> {
trash = null
trashError = ''
try {
trash = await fetchKnowledgeTrash()
} catch (e) {
trashError = errMsg(e)
trash = []
}
}
async function doRestore(slug: string): Promise<void> {
restoring = slug
try {
await restoreKnowledge(slug)
toast.success('Note restored')
onChanged()
await loadTrash()
} catch (e) {
toast.error(errMsg(e))
} finally {
restoring = null
}
}
function activate(t: typeof tab): void {
tab = t
if (t === 'duplicates' && clusters === null) loadDuplicates()
else if (t === 'tags' && tags === null) loadTags()
else if (t === 'orphans' && orphans === null) loadOrphans()
else if (t === 'trash' && trash === null) loadTrash()
}
// Initial tab's data.
loadDuplicates()
</script>
<div class="flex h-full flex-col gap-2">
<Tabs.Root bind:value={tab} class="flex min-h-0 flex-1 flex-col">
<Tabs.List class="h-8 w-fit">
<Tabs.Trigger value="duplicates" class="gap-1 text-xs" onclick={() => activate('duplicates')}>
<CopyIcon class="size-3.5" /> Duplicates
</Tabs.Trigger>
<Tabs.Trigger value="tags" class="gap-1 text-xs" onclick={() => activate('tags')}>
<TagIcon class="size-3.5" /> Tags
</Tabs.Trigger>
<Tabs.Trigger value="orphans" class="gap-1 text-xs" onclick={() => activate('orphans')}>
<GhostIcon class="size-3.5" /> Orphans
</Tabs.Trigger>
<Tabs.Trigger value="trash" class="gap-1 text-xs" onclick={() => activate('trash')}>
<Trash2Icon class="size-3.5" /> Trash
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="duplicates" class="min-h-0 flex-1 overflow-y-auto pt-2">
<p class="mb-2 text-xs text-muted-foreground">
Notes with near-identical titles, grouped for review — not a verdict. Pick a target and the
sources to fold into it; sources are soft-deleted afterward and stay recoverable from Trash.
</p>
{#if mergeError}
<p
class="mb-2 rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
>
{mergeError}
</p>
{/if}
{#if duplicatesError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{duplicatesError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadDuplicates}
>Retry</Button
>
</p>
{/if}
{#if clusters === null}
<div class="flex flex-col gap-3">
{#each Array(2) as _, ci (ci)}
<div class="overflow-hidden rounded-lg border">
<div
class="flex items-center justify-between gap-2 border-b bg-muted/30 px-2.5 py-1.5"
>
<Skeleton class="h-3 w-28" />
<Skeleton class="h-6 w-28" />
</div>
<div class="flex flex-col gap-2 p-2.5">
{#each Array(ci === 0 ? 3 : 2) as _, ri (ri)}
<Skeleton class="h-3.5" style="width: {70 - ri * 10}%" />
{/each}
</div>
</div>
{/each}
</div>
{:else if clusters.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">No likely duplicates found.</p>
{:else}
<div class="flex flex-col gap-3">
{#each clusters as c (c.members[0].slug)}
{@const key = c.members[0].slug}
{@const sourceCount = mergeSources[key]?.size ?? 0}
<div class="overflow-hidden rounded-lg border">
<div
class="flex items-center justify-between gap-2 border-b bg-muted/30 px-2.5 py-1.5"
>
<div class="flex min-w-0 items-center gap-2 text-xs">
<span class="font-medium">{c.members.length} similar notes</span>
<!-- Similarity as a meter rather than only a number: it's a
ratio, and the bar makes a 93% pileup visibly different
from a borderline 61% at a glance down a long list. -->
<span
class="hidden h-1 w-12 shrink-0 overflow-hidden rounded-full bg-primary/15 sm:block"
title="{(c.top_similarity * 100).toFixed(0)}% title similarity"
>
<span
class="block h-full rounded-full bg-primary"
style="width: {c.top_similarity * 100}%"
></span>
</span>
<span class="shrink-0 tabular-nums text-muted-foreground"
>{(c.top_similarity * 100).toFixed(0)}%</span
>
</div>
<Button
size="sm"
class="h-6 shrink-0 gap-1 text-xs"
disabled={merging === key || sourceCount === 0}
onclick={() => doMerge(key)}
>
{merging === key ? 'Merging…' : `Merge ${sourceCount} into target`}
</Button>
</div>
<!-- Two bare inputs per row read as "…what do these do?", so
name them once per cluster. An inline legend rather than
column headers: the controls are 14px wide and the words
are not, so headers sized to the columns just collide. -->
<p class="flex items-center gap-3 px-2.5 pt-2 text-[10px] text-muted-foreground">
<span class="flex items-center gap-1">
<span
class="inline-block size-2 rounded-full ring-1 ring-muted-foreground/60"
aria-hidden="true"
></span> keep as target
</span>
<span class="flex items-center gap-1">
<span
class="inline-block size-2 rounded-[2px] ring-1 ring-muted-foreground/60"
aria-hidden="true"
></span> fold into it
</span>
</p>
<div class="flex flex-col p-1">
{#each c.members as m (m.slug)}
{@const isTarget = mergeTarget[key] === m.slug}
{@const Icon = kindMeta(m.kind).icon}
<label
class="flex items-center gap-2 rounded px-1.5 py-1 text-xs {isTarget
? 'bg-primary/5'
: 'hover:bg-muted/40'}"
>
<input
type="radio"
class="size-3.5 shrink-0 accent-[var(--primary)]"
name="target-{key}"
aria-label="Keep &quot;{m.title}&quot; as the merge target"
checked={isTarget}
onchange={() =>
setMergeTarget(
key,
m.slug,
c.members.map((mm) => mm.slug)
)}
/>
<input
type="checkbox"
class="size-3.5 shrink-0 accent-[var(--primary)]"
aria-label="Fold &quot;{m.title}&quot; into the target"
disabled={isTarget}
checked={!isTarget && (mergeSources[key]?.has(m.slug) ?? false)}
onchange={() => toggleSource(key, m.slug)}
/>
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
<button
type="button"
class="min-w-0 flex-1 truncate text-left hover:underline {isTarget
? 'font-medium text-primary'
: ''}"
onclick={() => onSelect(m.slug)}
>
{m.title}
</button>
<span class="shrink-0 text-[10px] text-muted-foreground"
>{relativeTime(m.updated_at)}</span
>
{#if isTarget}
<Badge
variant="outline"
class="shrink-0 border-primary/40 text-[9px] text-primary">target</Badge
>
{/if}
</label>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="tags" class="min-h-0 flex-1 overflow-y-auto pt-2">
{#if tagsError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{tagsError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadTags}>Retry</Button>
</p>
{/if}
{#if tags === null}
<table class="w-full text-xs">
<thead>
<tr class="border-b text-left text-muted-foreground">
<th class="py-1 font-normal">Tag</th>
<th class="py-1 font-normal" colspan="2">Uses</th>
<th class="py-1 font-normal">Variants</th>
<th class="py-1"></th>
</tr>
</thead>
<tbody>
{#each Array(10) as _, i (i)}
<tr class="border-b border-border/50">
<td class="w-32 py-1.5 pr-2"
><Skeleton class="h-3" style="width: {60 - i * 3}%" /></td
>
<td class="w-8 py-1.5 pr-1"><Skeleton class="ml-auto h-3 w-4" /></td>
<td class="w-24 py-1.5 pr-3">
<Skeleton class="h-1 rounded-full" style="width: {90 - i * 8}%" />
</td>
<td class="py-1.5 pr-2"><Skeleton class="h-3 w-6" /></td>
<td class="py-1.5"></td>
</tr>
{/each}
</tbody>
</table>
{:else}
{@const maxUses = Math.max(1, ...tags.map((t) => t.uses))}
<table class="w-full text-xs">
<thead>
<tr class="border-b text-left text-muted-foreground">
<th class="py-1 font-normal">Tag</th>
<th class="py-1 font-normal" colspan="2">Uses</th>
<th class="py-1 font-normal">Variants</th>
<th class="py-1"></th>
</tr>
</thead>
<tbody>
{#each tags as t (t.tag)}
<tr class="border-b border-border/50">
<td class="py-1 pr-2">
{#if renaming === t.tag}
<div class="flex items-center gap-1">
<Input bind:value={renameDraft} class="h-6 w-32 text-xs" />
<Button
size="sm"
class="h-6 px-1.5"
disabled={renameBusy}
onclick={() => confirmRename(t)}
>
<CheckIcon class="size-3" />
</Button>
</div>
{:else}
<button
type="button"
class="font-mono hover:underline"
onclick={() => startRename(t)}
>
{t.tag}
</button>
{/if}
</td>
<!-- tabular-nums here (unlike the overview's standalone
figures): these are a column that has to line up. -->
<td class="w-8 py-1 pr-1 text-right tabular-nums">{t.uses}</td>
<td class="w-24 py-1 pr-3">
<!-- Magnitude, so: one hue, length-encoded, scaled to the
most-used tag. Recessive by design — it's a reading aid
down the column, not the subject of the table. -->
<span class="block h-1 overflow-hidden rounded-full bg-primary/10">
<span
class="block h-full rounded-full bg-primary/60"
style="width: {(t.uses / maxUses) * 100}%"
></span>
</span>
</td>
<td class="py-1 pr-2">
{#if t.split}
<span class="text-destructive">{t.variants.join(', ')}</span>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</td>
<td class="py-1 text-right">
{#if t.split}
<Button
size="sm"
variant="outline"
class="h-6 text-xs"
disabled={renameBusy}
onclick={() => normalize(t)}
>
Normalize
</Button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Tabs.Content>
<Tabs.Content value="orphans" class="min-h-0 flex-1 overflow-y-auto pt-2">
<p class="mb-2 text-xs text-muted-foreground">
Notes untagged, unlinked to any entity, or untouched for 90+ days — invisible to most
navigation paths and easy to lose track of.
{#if orphanCounts.untagged || orphanCounts.unlinked || orphanCounts.stale}
({orphanCounts.untagged ?? 0} untagged · {orphanCounts.unlinked ?? 0} unlinked · {orphanCounts.stale ??
0} stale)
{/if}
</p>
{#if orphansError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{orphansError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadOrphans}
>Retry</Button
>
</p>
{/if}
{#if orphans === null}
<div class="flex flex-col gap-0.5">
{#each Array(7) as _, i (i)}
<div class="flex items-center gap-2 px-1.5 py-1.5">
<Skeleton class="h-3.5 flex-1" style="max-width: {60 - (i % 4) * 8}%" />
<Skeleton class="h-4 w-14 shrink-0 rounded-full" />
<Skeleton class="h-3 w-10 shrink-0" />
</div>
{/each}
</div>
{:else if orphans.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">Nothing orphaned.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each orphans as o (o.slug)}
<button
type="button"
class="flex items-center gap-2 rounded px-1.5 py-1 text-left text-xs hover:bg-muted/40"
onclick={() => onSelect(o.slug)}
>
<span class="min-w-0 flex-1 truncate">{o.title}</span>
{#each o.reasons as r (r)}<Badge variant="outline" class="shrink-0 text-[9px]"
>{r}</Badge
>{/each}
<span class="shrink-0 text-[10px] text-muted-foreground"
>{relativeTime(o.updated_at)}</span
>
</button>
{/each}
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="trash" class="min-h-0 flex-1 overflow-y-auto pt-2">
{#if trashError}
<p class="mb-2 flex items-center gap-2 text-xs text-destructive">
{trashError}
<Button size="sm" variant="outline" class="h-6 text-xs" onclick={loadTrash}>Retry</Button>
</p>
{/if}
{#if trash === null}
<div class="flex flex-col gap-0.5">
{#each Array(4) as _, i (i)}
<div class="flex items-center gap-2 px-1.5 py-1.5">
<Skeleton class="h-3.5 flex-1" style="max-width: {55 - i * 6}%" />
<Skeleton class="h-3 w-32 shrink-0" />
<Skeleton class="h-6 w-16 shrink-0" />
</div>
{/each}
</div>
{:else if trash.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">Trash is empty.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each trash as t (t.slug)}
<div class="flex items-center gap-2 rounded px-1.5 py-1 text-xs hover:bg-muted/40">
<span class="min-w-0 flex-1 truncate">{t.title}</span>
<span class="shrink-0 text-[10px] text-muted-foreground">
deleted {relativeTime(t.deleted_at)} by {t.deleted_by || 'unknown'}
</span>
<Button
size="sm"
variant="outline"
class="h-6 shrink-0 gap-1 text-xs"
disabled={restoring === t.slug}
onclick={() => doRestore(t.slug)}
>
<RotateCcwIcon class="size-3" /> Restore
</Button>
</div>
{/each}
</div>
{/if}
</Tabs.Content>
</Tabs.Root>
</div>

View File

@@ -0,0 +1,124 @@
<script lang="ts">
// Right pane: the discovery half of the wiki. From any note you can walk
// to the entity it's about, and from there sideways to every other note
// that concerns the same entity or shares a tag — this is what makes the
// knowledge base a graph to browse rather than a flat list to scroll.
//
// "Related" and "tag neighbours" are derived client-side from the list
// already loaded by Knowledge.svelte (KnowledgeListItem carries `about`
// and `tags`), not a separate endpoint — with ~100 notes total, filtering
// an in-memory array is cheaper and simpler than a bespoke backlinks
// query, and it's exactly the same data WikiTree's group-by-entity/tag
// modes already use.
import type { KnowledgeListItem } from '$lib/api'
import { openEntityWindow } from '$lib/stores/windows'
import DetailSection from '$lib/components/DetailSection.svelte'
import { kindMeta } from './kinds'
import LinkIcon from '@lucide/svelte/icons/link'
let {
item,
allItems,
onSelect
}: {
item: KnowledgeListItem | null
allItems: KnowledgeListItem[]
onSelect: (slug: string) => void
} = $props()
const related = $derived.by(() => {
if (!item || item.about.length === 0) return []
const aboutSet = new Set(item.about)
return allItems
.filter((it) => it.slug !== item.slug && it.about.some((s) => aboutSet.has(s)))
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
})
const tagNeighbours = $derived.by(() => {
if (!item || item.tags.length === 0) return []
const tagSet = new Set(item.tags)
return allItems
.filter((it) => it.slug !== item.slug && it.tags.some((t) => tagSet.has(t)))
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
.slice(0, 20) // common tags (e.g. "backup") can otherwise pull in most of the KB
})
</script>
<div class="flex h-full flex-col gap-2 overflow-y-auto pr-1">
{#if !item}
<p class="py-8 text-center text-xs text-muted-foreground">Nothing selected.</p>
{:else}
<DetailSection title="About" count={item.about.length} defaultOpen={true}>
{#if item.about.length === 0}
<p class="text-xs text-muted-foreground">Not linked to any entity.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each item.about as slug (slug)}
<button
type="button"
class="flex items-center gap-1.5 rounded px-1 py-0.5 text-left font-mono text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground"
onclick={() => openEntityWindow(slug)}
>
<LinkIcon class="size-3 shrink-0" />
<span class="truncate">{slug}</span>
</button>
{/each}
</div>
{/if}
</DetailSection>
<DetailSection
title="Also about these entities"
count={related.length}
defaultOpen={related.length > 0}
>
{#if related.length === 0}
<p class="text-xs text-muted-foreground">No other notes share a linked entity.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each related as it (it.slug)}
{@const Icon = kindMeta(it.kind).icon}
<button
type="button"
class="group flex items-center gap-1.5 rounded px-1 py-1 text-left text-xs hover:bg-muted/50"
onclick={() => onSelect(it.slug)}
title={it.title}
>
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
<span class="min-w-0 flex-1 truncate group-hover:text-primary">{it.title}</span>
</button>
{/each}
</div>
{/if}
</DetailSection>
<!-- Only auto-open a *tight* neighbour set. A generic tag like "container"
is on 20 notes, and expanding all of those by default buries the
stronger entity-based links above it under a wall of weak matches;
a handful of shared-tag notes is a real cluster worth surfacing. -->
<DetailSection
title="Tag neighbours"
count={tagNeighbours.length}
defaultOpen={related.length === 0 && tagNeighbours.length > 0 && tagNeighbours.length <= 6}
>
{#if tagNeighbours.length === 0}
<p class="text-xs text-muted-foreground">No other notes share a tag.</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each tagNeighbours as it (it.slug)}
{@const Icon = kindMeta(it.kind).icon}
<button
type="button"
class="group flex items-center gap-1.5 rounded px-1 py-1 text-left text-xs hover:bg-muted/50"
onclick={() => onSelect(it.slug)}
title={it.title}
>
<Icon class="size-3 shrink-0 text-muted-foreground/70" />
<span class="min-w-0 flex-1 truncate group-hover:text-primary">{it.title}</span>
</button>
{/each}
</div>
{/if}
</DetailSection>
{/if}
</div>

View File

@@ -0,0 +1,139 @@
<script lang="ts">
// "New note" dialog — the create half of the wiki. A plain toggle group for
// kind (document/investigation/runbook) rather than the Select primitive:
// three fixed, always-visible options don't need a popover, and this
// mirrors the same toggle-group pattern WikiTree already uses for its
// group-by switch.
import { createKnowledge, KnowledgeApiError } from '$lib/api'
import * as Dialog from '$lib/components/ui/dialog'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import { Textarea } from '$lib/components/ui/textarea'
let {
open = $bindable(false),
onCreated
}: {
open: boolean
onCreated: (slug: string) => void
} = $props()
const KINDS = ['document', 'investigation', 'runbook'] as const
type Kind = (typeof KINDS)[number]
let title = $state('')
let kind = $state<Kind>('document')
let folder = $state('')
let tags = $state('')
let content = $state('')
let saving = $state(false)
let error = $state('')
function reset(): void {
title = ''
kind = 'document'
folder = ''
tags = ''
content = ''
error = ''
}
async function submit(): Promise<void> {
if (!title.trim() || !content.trim()) {
error = 'Title and content are required.'
return
}
saving = true
error = ''
try {
const result = await createKnowledge({
title: title.trim(),
content: content.trim(),
kind,
folder: folder.trim() || undefined,
tags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean)
})
onCreated(result.slug)
open = false
reset()
} catch (e) {
error =
e instanceof KnowledgeApiError
? `${e.message}${e.detail ? ` — ${e.detail}` : ''}`
: 'Create failed.'
} finally {
saving = false
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-lg">
<Dialog.Header>
<Dialog.Title>New knowledge note</Dialog.Title>
</Dialog.Header>
<div class="flex flex-col gap-3">
{#if error}
<p
class="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
>
{error}
</p>
{/if}
<label class="flex flex-col gap-1 text-xs text-muted-foreground" for="new-note-title">
Title
<Input id="new-note-title" bind:value={title} placeholder="Short, specific, searchable" />
</label>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">Type</span>
<div class="inline-flex overflow-hidden rounded-md border">
{#each KINDS as k (k)}
<button
type="button"
class="px-2 py-1 text-xs {kind === k
? 'bg-secondary text-secondary-foreground'
: 'hover:bg-muted/50'}"
onclick={() => (kind = k)}
>
{k}
</button>
{/each}
</div>
</div>
<label class="flex flex-col gap-1 text-xs text-muted-foreground" for="new-note-folder">
Folder <span class="text-muted-foreground/70">(optional — defaults to "operator")</span>
<Input
id="new-note-folder"
bind:value={folder}
placeholder="e.g. containers, infrastructure"
/>
</label>
<label class="flex flex-col gap-1 text-xs text-muted-foreground" for="new-note-tags">
Tags <span class="text-muted-foreground/70">(comma-separated, optional)</span>
<Input id="new-note-tags" bind:value={tags} placeholder="oom, rclone, gotcha" />
</label>
<label class="flex flex-col gap-1 text-xs text-muted-foreground" for="new-note-content">
Content (markdown)
<Textarea
id="new-note-content"
bind:value={content}
class="min-h-[160px] font-mono text-xs"
/>
</label>
</div>
<Dialog.Footer>
<Button variant="ghost" onclick={() => (open = false)} disabled={saving}>Cancel</Button>
<Button onclick={submit} disabled={saving}>{saving ? 'Creating…' : 'Create'}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,201 @@
<script lang="ts">
// The reader pane's resting state — what you see every time the app opens
// and nothing is selected yet.
//
// This used to be the sentence "Select a note, or create a new one."
// centred in an otherwise empty 56%-width pane: the single most-seen screen
// in the app doing no work at all. It's now the landing view, and it also
// restores the collection-level numbers the wiki redesign dropped (the old
// stats-only Knowledge page led with them, and they were the one thing that
// page did well — "the system is getting smarter" is only visible in
// aggregate).
//
// Every figure is derived from the `items` array the parent already loaded
// for the tree, so this panel costs no extra request.
import type { KnowledgeListItem } from '$lib/api'
import { kindMeta, isAgentAuthored } from './kinds'
import { relativeTime } from '$lib/utils'
import BotIcon from '@lucide/svelte/icons/bot'
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import ClockIcon from '@lucide/svelte/icons/clock'
import HashIcon from '@lucide/svelte/icons/hash'
import PlusIcon from '@lucide/svelte/icons/plus'
import { Button } from '$lib/components/ui/button'
let {
items,
onSelect,
onNew
}: {
items: KnowledgeListItem[]
onSelect: (slug: string) => void
onNew: () => void
} = $props()
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
// Postgres renders timestamptz as "2026-07-26 10:50:53.475644+00" — a space
// instead of ISO-8601's 'T', and a bare two-digit offset. V8 happens to
// accept that verbatim, but Safari's parser requires the 'T' AND an offset
// of 'Z' or ±HH:MM, so both have to be normalised together: swapping only
// the separator yields "…475644+00", which is invalid ISO and parses to NaN
// *everywhere* — strictly worse than leaving the string alone.
function parseTimestamp(raw: string): number {
return Date.parse(raw.replace(' ', 'T').replace(/([+-]\d{2})$/, '$1:00'))
}
const stats = $derived.by(() => {
const now = Date.now()
let agent = 0
let lastWeek = 0
const byKind = new Map<string, number>()
const tagCounts = new Map<string, number>()
for (const it of items) {
if (isAgentAuthored(it.edited_by)) agent++
const ts = parseTimestamp(it.updated_at)
if (!Number.isNaN(ts) && now - ts < WEEK_MS) lastWeek++
byKind.set(it.kind, (byKind.get(it.kind) ?? 0) + 1)
for (const t of it.tags) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1)
}
return {
total: items.length,
agent,
lastWeek,
byKind,
topTags: [...tagCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
}
})
// Share of the collection the agent wrote — the "is this thing actually
// learning" number, and the only ratio here worth a meter rather than
// another tile.
const agentShare = $derived(stats.total === 0 ? 0 : Math.round((stats.agent / stats.total) * 100))
const recent = $derived(
[...items].sort((a, b) => b.updated_at.localeCompare(a.updated_at)).slice(0, 6)
)
// Kinds in a fixed order so the row doesn't reshuffle as counts change.
const KIND_ORDER = ['runbook', 'investigation', 'document'] as const
</script>
<div class="mx-auto flex h-full w-full max-w-2xl flex-col gap-7 overflow-y-auto px-1 py-6">
{#if stats.total === 0}
<!-- Genuinely empty collection (not a failed load — the parent handles
that case before rendering this component). -->
<div class="flex flex-1 flex-col items-center justify-center gap-3 text-center">
<h2 class="text-lg font-semibold">Nothing here yet</h2>
<p class="max-w-sm text-sm text-muted-foreground">
The knowledge base is empty. Write the first note, or let Nomos record what it learns as it
works.
</p>
<Button size="sm" class="gap-1.5" onclick={onNew}>
<PlusIcon class="size-3.5" /> New note
</Button>
</div>
{:else}
<!-- Hero: the one number the view leads with. Sans, not the Inknut
heading face — a serif at display size reads as decoration rather
than data. Proportional figures (no tabular-nums): this is a
standalone value, not a column that has to align. -->
<div>
<h2 class="text-sm font-medium tracking-wide text-muted-foreground uppercase">
Knowledge base
</h2>
<div class="mt-1 flex items-baseline gap-2.5">
<span class="font-sans text-5xl leading-none font-semibold">{stats.total}</span>
<span class="text-sm text-muted-foreground">notes</span>
</div>
</div>
<!-- KPI row. Hairline dividers rather than boxed cards: at four items the
boxes were doing more visual work than the numbers inside them. -->
<div class="grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-border/60 sm:grid-cols-4">
{#each KIND_ORDER as k (k)}
{@const meta = kindMeta(k)}
{@const Icon = meta.icon}
<div class="flex flex-col gap-1 bg-card px-3 py-2.5">
<span class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Icon class="size-3.5" />
{meta.plural}
</span>
<span class="text-xl font-semibold">{stats.byKind.get(k) ?? 0}</span>
</div>
{/each}
<div class="flex flex-col gap-1 bg-card px-3 py-2.5">
<span class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<SparklesIcon class="size-3.5" /> this week
</span>
<span class="text-xl font-semibold">{stats.lastWeek}</span>
</div>
</div>
<!-- Meter: one ratio, one hue. Track is a lighter step of the fill's own
ramp so the whole bar reads as a single scale. -->
<div class="flex flex-col gap-1.5">
<div class="flex items-baseline justify-between text-xs">
<span class="flex items-center gap-1.5 text-muted-foreground">
<BotIcon class="size-3.5" /> Written by Nomos
</span>
<span class="text-muted-foreground">
<span class="font-semibold text-foreground">{stats.agent}</span> of {stats.total} · {agentShare}%
</span>
</div>
<div
class="h-1.5 overflow-hidden rounded-full bg-primary/15"
role="meter"
aria-valuenow={agentShare}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Share of notes written by Nomos"
>
<div class="h-full rounded-full bg-primary" style="width: {agentShare}%"></div>
</div>
</div>
<div class="flex flex-col gap-2">
<h3 class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<ClockIcon class="size-3.5" /> Recently updated
</h3>
<div class="flex flex-col">
{#each recent as it (it.slug)}
{@const Icon = kindMeta(it.kind).icon}
<button
type="button"
class="group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/50"
onclick={() => onSelect(it.slug)}
>
<Icon class="size-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate text-sm group-hover:text-primary">{it.title}</span>
{#if isAgentAuthored(it.edited_by)}
<BotIcon class="size-3 shrink-0 text-muted-foreground" />
{/if}
<span class="shrink-0 text-[11px] text-muted-foreground"
>{relativeTime(it.updated_at)}</span
>
</button>
{/each}
</div>
</div>
{#if stats.topTags.length > 0}
<div class="flex flex-col gap-2">
<h3 class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<HashIcon class="size-3.5" /> Busiest tags
</h3>
<div class="flex flex-wrap gap-1.5">
{#each stats.topTags as [tag, count] (tag)}
<span
class="flex items-center gap-1 border px-2 py-0.5 text-[11px] text-muted-foreground"
>
{tag}
<span class="text-foreground/70 tabular-nums">{count}</span>
</span>
{/each}
</div>
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,119 @@
<script lang="ts">
// Cmd/Ctrl+K quick-open over every note title — the fast path once you
// already know roughly what you're looking for, as opposed to WikiTree's
// browse-by-group path for when you don't. Built on the Dialog primitive
// + a plain filtered list rather than shadcn-svelte's `command` component:
// that component's interactive CLI installer couldn't be driven
// non-interactively in this environment (it prompts to resolve overlapping
// dependency files), and re-deriving the same arrow-key/Enter list nav by
// hand here is a small amount of code for something this self-contained.
import type { KnowledgeListItem } from '$lib/api'
import * as Dialog from '$lib/components/ui/dialog'
import { Input } from '$lib/components/ui/input'
import StatusBadge from '$lib/components/StatusBadge.svelte'
import { onDestroy } from 'svelte'
let {
open = $bindable(false),
items,
onSelect
}: {
open: boolean
items: KnowledgeListItem[]
onSelect: (slug: string) => void
} = $props()
let query = $state('')
let activeIndex = $state(0)
let inputEl = $state<HTMLInputElement | null>(null)
const results = $derived.by(() => {
const q = query.trim().toLowerCase()
const pool = q
? items.filter(
(it) =>
it.title.toLowerCase().includes(q) ||
it.slug.toLowerCase().includes(q) ||
it.tags.some((t) => t.toLowerCase().includes(q))
)
: items
return pool.slice(0, 30) // 102 notes total — cap the render, not the match
})
$effect(() => {
void results // dependency only — re-run when the result set changes
activeIndex = 0
})
// Reset on every open so quick-open never remembers the last search, and
// focus the input once the dialog has actually mounted it.
$effect(() => {
if (open) {
query = ''
queueMicrotask(() => inputEl?.focus())
}
})
function choose(slug: string): void {
onSelect(slug)
open = false
}
function handleKeydown(e: KeyboardEvent): void {
if (!open) return
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex = Math.min(activeIndex + 1, results.length - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex = Math.max(activeIndex - 1, 0)
} else if (e.key === 'Enter') {
e.preventDefault()
const hit = results[activeIndex]
if (hit) choose(hit.slug)
}
}
// A window-level listener rather than one on Dialog.Content: bits-ui's
// Dialog renders its content through a portal with its own focus-trap
// wiring, and an onkeydown prop passed straight through to Content did not
// reliably receive ArrowDown/Enter in testing (focus landing inside the
// trap didn't guarantee the event reached the element this component
// attached the listener to). Capturing at the window and gating on `open`
// sidesteps that entirely — Escape-to-close is still bits-ui's own
// behavior, this only adds the list-navigation keys.
window.addEventListener('keydown', handleKeydown)
onDestroy(() => window.removeEventListener('keydown', handleKeydown))
</script>
<Dialog.Root bind:open>
<Dialog.Content
class="top-[20%] max-w-lg -translate-y-0 gap-0 p-0 sm:max-w-lg"
showCloseButton={false}
>
<Input
bind:ref={inputEl}
bind:value={query}
placeholder="Jump to a note…"
class="h-11 rounded-b-none border-0 border-b px-3 text-sm focus-visible:ring-0"
/>
<div class="max-h-80 overflow-y-auto p-1">
{#each results as it, i (it.slug)}
<button
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm {i ===
activeIndex
? 'bg-primary/10 text-primary'
: 'hover:bg-muted/50'}"
onclick={() => choose(it.slug)}
onmouseenter={() => (activeIndex = i)}
>
<span class="min-w-0 flex-1 truncate">{it.title}</span>
<StatusBadge kind="type" value={it.kind} class="shrink-0 text-[9px]" />
</button>
{:else}
<p class="py-6 text-center text-xs text-muted-foreground">No notes match "{query}".</p>
{/each}
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,485 @@
<script lang="ts">
// Center pane: read a note, edit it in place, or browse its history.
//
// `item` carries the list-derived metadata (kind, tags, about, edited_by —
// everything WikiTree already has); the full body is fetched here lazily
// per selection, same split as the API (serveKnowledgeList never returns
// content — see knowledge_write.go — so the tree stays cheap and only the
// note actually being read pays for its body).
import {
fetchKnowledgeContent,
fetchKnowledgeRevisions,
updateKnowledge,
deleteKnowledge,
KnowledgeApiError,
type KnowledgeListItem,
type KnowledgeContent,
type KnowledgeRevision
} from '$lib/api'
import { renderWikiMarkdown, slugFromKbHref, diffLines } from './wikiText'
import { kindMeta, isAgentAuthored } from './kinds'
import WikiOverview from './WikiOverview.svelte'
import { openEntityWindow } from '$lib/stores/windows'
import { relativeTime } from '$lib/utils'
import { toast } from 'svelte-sonner'
import * as Tabs from '$lib/components/ui/tabs'
import * as Dialog from '$lib/components/ui/dialog'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import { Textarea } from '$lib/components/ui/textarea'
import { Skeleton } from '$lib/components/ui/skeleton'
import PencilIcon from '@lucide/svelte/icons/pencil'
import TrashIcon from '@lucide/svelte/icons/trash-2'
import HistoryIcon from '@lucide/svelte/icons/history'
import BotIcon from '@lucide/svelte/icons/bot'
import XIcon from '@lucide/svelte/icons/x'
import SaveIcon from '@lucide/svelte/icons/save'
let {
item,
allItems,
knownSlugs,
onNavigate,
onNew,
onChanged,
dirty = $bindable(false)
}: {
item: KnowledgeListItem | null
// The whole collection — only used for the resting-state overview shown
// when nothing is selected (WikiOverview derives its figures from it).
allItems: KnowledgeListItem[]
knownSlugs: Set<string>
onNavigate: (slug: string) => void
onNew: () => void
// Fired after a save or delete that the parent's cached list needs to
// reflect (title/tags changed, or the note is gone). Parent decides
// whether to refetch the whole list or patch locally.
onChanged: () => void
// True while there's an in-progress edit that would be silently
// discarded if `item` changed out from under this component. Knowledge.svelte
// reads this before switching the selection (tree click, quick-open,
// etc.) so it can confirm with the operator first — see its
// requestSelect. Deliberately "in edit mode" rather than a real dirty
// diff against the loaded content: simpler, and erring toward "ask
// even if nothing actually changed" is the safe direction for a
// destructive-by-default operation.
dirty?: boolean
} = $props()
let content = $state<KnowledgeContent | null>(null)
let loading = $state(false)
let mode = $state<'read' | 'edit'>('read')
let tab = $state<'note' | 'history'>('note')
let saveError = $state('')
let saving = $state(false)
let draftTitle = $state('')
let draftContent = $state('')
let draftTags = $state('')
let draftAbout = $state('')
let revisions = $state<KnowledgeRevision[] | null>(null)
let revisionsLoading = $state(false)
let selectedRevisionId = $state<number | null>(null)
async function load(slug: string): Promise<void> {
loading = true
mode = 'read'
dirty = false
tab = 'note'
revisions = null
selectedRevisionId = null
saveError = ''
content = await fetchKnowledgeContent(slug)
loading = false
}
$effect(() => {
if (item) load(item.slug)
else content = null
})
function startEdit(): void {
if (!content || !item) return
draftTitle = content.title
draftContent = content.content
draftTags = content.tags.join(', ')
draftAbout = item.about.join(', ')
saveError = ''
mode = 'edit'
dirty = true
}
function cancelEdit(): void {
mode = 'read'
dirty = false
saveError = ''
}
async function save(): Promise<void> {
if (!item) return
const title = draftTitle.trim()
const body = draftContent.trim()
if (!title || !body) {
saveError = 'Title and content cannot be empty.'
return
}
saving = true
saveError = ''
const about = draftAbout
.split(',')
.map((s) => s.trim())
.filter(Boolean)
try {
const result = await updateKnowledge(item.slug, {
title,
content: body,
tags: draftTags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
about
})
// A typo'd entity slug in "About" fails to link server-side with only
// a log line (see linkKnowledgeAbout) — diff what came back against
// what was submitted so that doesn't happen silently.
const unresolved = about.filter((s) => !result.linked?.includes(s))
if (unresolved.length > 0) {
toast.error(`Couldn't link to: ${unresolved.join(', ')} — check the slug is correct.`)
}
mode = 'read'
dirty = false
await load(item.slug)
onChanged()
} catch (e) {
saveError =
e instanceof KnowledgeApiError
? `${e.message}${e.detail ? ` — ${e.detail}` : ''}`
: 'Save failed.'
} finally {
saving = false
}
}
let confirmDeleteOpen = $state(false)
let deleting = $state(false)
// Soft delete (see migrations/022_knowledge_revisions.up.sql) — the note
// goes to trash and can be brought back, so this is a lightweight confirm
// rather than anything heavier. It's an in-app Dialog rather than the
// browser's native confirm(): this app runs inside a custom floating
// window (its own desktop-shell chrome), and a native confirm() blocks
// the entire page's JS event loop until dismissed — in testing that froze
// the tab hard enough that automated clicks stopped registering
// entirely. A real dialog stays inside Svelte's event handling and can't
// wedge the app that way.
async function confirmDelete(): Promise<void> {
if (!item) return
deleting = true
try {
await deleteKnowledge(item.slug)
confirmDeleteOpen = false
onChanged()
} catch (e) {
saveError = e instanceof KnowledgeApiError ? e.message : 'Delete failed.'
} finally {
deleting = false
}
}
async function openHistory(): Promise<void> {
tab = 'history'
if (revisions !== null || !item) return
revisionsLoading = true
revisions = await fetchKnowledgeRevisions(item.slug)
selectedRevisionId = revisions[0]?.id ?? null
revisionsLoading = false
}
// Bare slugs are auto-linked (see wikiText.ts) as `#kb:<slug>` anchors.
// Intercepted here via event delegation on the rendered container — the
// markdown body is injected with {@html}, so component-level click
// bindings can't attach to individual links, but a plain bubbling
// listener on the wrapper works the same as it would for real DOM.
function handleContentClick(e: MouseEvent): void {
const anchor = (e.target as HTMLElement).closest('a')
if (!anchor) return
const slug = slugFromKbHref(anchor.getAttribute('href'))
if (!slug) return
e.preventDefault()
if (knownSlugs.has(slug)) onNavigate(slug)
else openEntityWindow(slug)
}
const selectedRevision = $derived(revisions?.find((r) => r.id === selectedRevisionId) ?? null)
// Diff against the CURRENT live body, not the next revision — the History
// tab answers "what did this look like before it became what it is now,"
// not "what changed between two arbitrary edits."
const diff = $derived(
selectedRevision && content ? diffLines(selectedRevision.content, content.content) : null
)
</script>
<div class="flex h-full min-w-0 flex-col gap-2">
{#if !item}
<WikiOverview items={allItems} onSelect={onNavigate} {onNew} />
{:else if loading}
<!-- Shaped like the loaded header/tags/body below rather than a
centered spinner, so the switch from "loading" to "loaded" is a
content swap, not a layout jump — the title, meta line, tag row,
and first few lines of body all keep their real position. -->
<div class="flex items-start justify-between gap-2 border-b pb-2.5">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<Skeleton class="size-4 shrink-0 rounded" />
<Skeleton class="h-5 w-56" />
</div>
<div class="mt-2 flex items-center gap-2">
<Skeleton class="h-3 w-16" />
<Skeleton class="h-3 w-20" />
<Skeleton class="h-3 w-24" />
</div>
</div>
<Skeleton class="h-7 w-16 shrink-0" />
</div>
<div class="flex flex-wrap gap-1.5 pt-3">
<Skeleton class="h-5 w-14 rounded-full" />
<Skeleton class="h-5 w-16 rounded-full" />
</div>
<div class="flex flex-col gap-2.5 pt-2">
{#each Array(6) as _, i (i)}
<Skeleton class="h-4" style="width: {i === 5 ? 45 : 96 - i * 4}%" />
{/each}
</div>
{:else if !content}
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
Couldn't load this note.
</div>
{:else}
<!-- Header: kind + title, then a single provenance line. Previously these
were one wrapping row of badges and text fragments; splitting
"what this is" from "where it came from" stops the title competing
with its own metadata. -->
<div class="flex items-start justify-between gap-2 border-b pb-2.5">
<div class="min-w-0 flex-1">
{#if mode === 'edit'}
<Input bind:value={draftTitle} class="mb-1 h-8 font-medium" placeholder="Title" />
{:else}
{@const KindIcon = kindMeta(item.kind).icon}
<div class="flex min-w-0 items-center gap-2">
<KindIcon class="size-4 shrink-0 text-muted-foreground" />
<h2 class="truncate text-base font-semibold">{content.title}</h2>
</div>
{/if}
<div
class="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground"
>
<span class="capitalize">{kindMeta(item.kind).label}</span>
<span aria-hidden="true">·</span>
{#if isAgentAuthored(content.edited_by)}
<span class="flex items-center gap-1 text-primary">
<BotIcon class="size-3" /> Nomos
</span>
{:else if content.edited_by}
<span>{content.edited_by}</span>
{:else}
<span>unknown author</span>
{/if}
<span aria-hidden="true">·</span>
<span>updated {relativeTime(content.updated_at)}</span>
{#if content.revisions > 0}
<span aria-hidden="true">·</span>
<button
type="button"
class="underline decoration-dotted underline-offset-2 hover:text-foreground"
onclick={openHistory}
>
{content.revisions} revision{content.revisions === 1 ? '' : 's'}
</button>
{/if}
</div>
</div>
<div class="flex shrink-0 gap-1">
{#if mode === 'read'}
<Button size="sm" variant="outline" class="h-7 gap-1 text-xs" onclick={startEdit}>
<PencilIcon class="size-3.5" /> Edit
</Button>
<Button
size="sm"
variant="ghost"
class="h-7 gap-1 text-xs text-destructive"
onclick={() => (confirmDeleteOpen = true)}
>
<TrashIcon class="size-3.5" />
</Button>
{:else}
<Button
size="sm"
variant="ghost"
class="h-7 gap-1 text-xs"
onclick={cancelEdit}
disabled={saving}
>
<XIcon class="size-3.5" /> Cancel
</Button>
<Button size="sm" class="h-7 gap-1 text-xs" onclick={save} disabled={saving}>
<SaveIcon class="size-3.5" />
{saving ? 'Saving…' : 'Save'}
</Button>
{/if}
</div>
</div>
{#if saveError}
<p
class="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
>
{saveError}
</p>
{/if}
{#if mode === 'edit'}
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<Textarea
bind:value={draftContent}
class="min-h-[240px] flex-1 resize-none font-mono text-xs"
placeholder="Markdown content…"
/>
<label class="text-xs text-muted-foreground" for="wiki-tags">
Tags (comma-separated)
<Input
id="wiki-tags"
bind:value={draftTags}
class="mt-1 h-7 text-xs"
placeholder="oom, rclone, gotcha"
/>
</label>
<label class="text-xs text-muted-foreground" for="wiki-about">
About (entity slugs, comma-separated)
<Input
id="wiki-about"
bind:value={draftAbout}
class="mt-1 h-7 text-xs"
placeholder="host:strong, lxc:gitea"
/>
</label>
</div>
{:else}
<Tabs.Root bind:value={tab} class="flex min-h-0 flex-1 flex-col">
<Tabs.List class="h-7 w-fit">
<Tabs.Trigger value="note" class="text-xs">Note</Tabs.Trigger>
<Tabs.Trigger value="history" class="gap-1 text-xs" onclick={openHistory}>
<HistoryIcon class="size-3" /> History
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="note" class="min-h-0 flex-1 overflow-y-auto pt-3">
{#if item.tags.length}
<div class="mb-3 flex max-w-[68ch] flex-wrap gap-1.5">
{#each item.tags as t (t)}<span
class="border px-2 py-0.5 text-[11px] text-muted-foreground"
>{t}</span
>{/each}
</div>
{/if}
<!-- event delegation over rendered markdown: the interactive elements are the <a>
tags inside, already keyboard-operable on their own. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- max-w-[68ch]: without a measure the body ran the full width of a
resizable pane, which at a wide split is well past the ~75ch
where prose stops being comfortable to read. -->
<div
class="markdown-body max-w-[68ch] text-sm leading-relaxed"
onclick={handleContentClick}
>
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify in renderWikiMarkdown -->
{@html renderWikiMarkdown(content.content)}
</div>
</Tabs.Content>
<Tabs.Content value="history" class="min-h-0 flex-1 overflow-y-auto pt-2">
{#if revisionsLoading}
<div class="flex gap-3">
<div class="flex w-40 shrink-0 flex-col gap-2 px-2 py-1">
{#each Array(4) as _, i (i)}
<div class="flex flex-col gap-1">
<Skeleton class="h-3 w-16" />
<Skeleton class="h-2.5 w-20" />
</div>
{/each}
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1.5 rounded border p-2">
{#each Array(8) as _, i (i)}
<Skeleton class="h-3" style="width: {90 - (i % 4) * 15}%" />
{/each}
</div>
</div>
{:else if !revisions || revisions.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">
No prior revisions — this is the first version.
</p>
{:else}
<div class="flex gap-3">
<div class="flex w-40 shrink-0 flex-col gap-0.5">
{#each revisions as rev (rev.id)}
<button
type="button"
class="rounded px-2 py-1 text-left text-[11px] hover:bg-muted/50 {selectedRevisionId ===
rev.id
? 'bg-primary/10 text-primary'
: ''}"
onclick={() => (selectedRevisionId = rev.id)}
>
<div class="font-medium">{relativeTime(rev.version_at)}</div>
<div class="text-muted-foreground">{rev.edited_by || 'unknown'}</div>
</button>
{/each}
</div>
<div class="min-w-0 flex-1 overflow-x-auto rounded border">
{#if diff}
<pre class="p-2 text-[11px] leading-relaxed">{#each diff as op, i (i)}<div
class={op.type === 'add'
? 'bg-success/10 text-success'
: op.type === 'remove'
? 'bg-destructive/10 text-destructive line-through'
: ''}>{op.type === 'add'
? '+ '
: op.type === 'remove'
? '- '
: ' '}{op.line}</div>{/each}</pre>
{/if}
</div>
</div>
{/if}
</Tabs.Content>
</Tabs.Root>
{/if}
{/if}
</div>
<Dialog.Root bind:open={confirmDeleteOpen}>
<Dialog.Content class="sm:max-w-sm">
<Dialog.Header>
<Dialog.Title>Delete note?</Dialog.Title>
<Dialog.Description>
{#if item}"{item.title}" will move to Trash and can be restored from there.{/if}
</Dialog.Description>
</Dialog.Header>
{#if saveError}
<p
class="rounded border border-destructive/30 bg-destructive/5 px-2 py-1 text-xs text-destructive"
>
{saveError}
</p>
{/if}
<Dialog.Footer>
<Button variant="ghost" onclick={() => (confirmDeleteOpen = false)} disabled={deleting}
>Cancel</Button
>
<Button variant="destructive" onclick={confirmDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,198 @@
<script lang="ts">
// Left pane of the Knowledge wiki: a tree over every live note, with a
// grouping switch so the same 102 notes are reachable four different
// ways — which one helps depends on what the operator already remembers
// about the thing they're looking for (its topic, its type, a tag, or the
// machine it concerns).
import type { KnowledgeListItem } from '$lib/api'
import { groupNotes, type GroupBy } from './wikiText'
import { Input } from '$lib/components/ui/input'
import * as Select from '$lib/components/ui/select'
import * as Collapsible from '$lib/components/ui/collapsible'
import { Button } from '$lib/components/ui/button'
import { kindMeta, isAgentAuthored } from './kinds'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
import SearchIcon from '@lucide/svelte/icons/search'
import PlusIcon from '@lucide/svelte/icons/plus'
import BotIcon from '@lucide/svelte/icons/bot'
import FolderTreeIcon from '@lucide/svelte/icons/folder-tree'
let {
items,
selectedSlug,
onSelect,
onNew
}: {
items: KnowledgeListItem[]
selectedSlug: string | null
onSelect: (slug: string) => void
onNew: () => void
} = $props()
const GROUP_LABELS: Record<GroupBy, string> = {
folder: 'Folder',
kind: 'Type',
tag: 'Tag',
entity: 'Entity'
}
function loadGroupBy(): GroupBy {
if (typeof localStorage === 'undefined') return 'folder'
const v = localStorage.getItem('oikos-wiki-groupby')
return v === 'kind' || v === 'tag' || v === 'entity' ? v : 'folder'
}
let groupBy = $state<GroupBy>(loadGroupBy())
let filter = $state('')
function setGroupBy(v: string): void {
if (v !== 'folder' && v !== 'kind' && v !== 'tag' && v !== 'entity') return
groupBy = v
if (typeof localStorage !== 'undefined') localStorage.setItem('oikos-wiki-groupby', v)
}
const filtered = $derived.by(() => {
const q = filter.trim().toLowerCase()
if (!q) return items
return items.filter(
(it) =>
it.title.toLowerCase().includes(q) ||
it.slug.toLowerCase().includes(q) ||
it.tags.some((t) => t.toLowerCase().includes(q))
)
})
const groups = $derived(groupNotes(filtered, groupBy))
// Every group starts open when the filter is active (so a match is never
// hidden inside a collapsed group) and only the group containing the
// current selection starts open otherwise — with 102 notes across ~15
// folders, all-open-by-default would just be a long undifferentiated
// scroll.
let openGroups = $state<Set<string>>(new Set())
$effect(() => {
if (filter.trim()) {
openGroups = new Set(groups.map((g) => g.key))
return
}
const owning = groups.find((g) => g.items.some((it) => it.slug === selectedSlug))
openGroups = new Set(owning ? [owning.key] : groups[0] ? [groups[0].key] : [])
})
function toggleGroup(key: string): void {
const next = new Set(openGroups)
if (next.has(key)) next.delete(key)
else next.add(key)
openGroups = next
}
</script>
<div class="flex h-full flex-col gap-1.5">
<!-- Search and "new note" share a row: both act on the list as a whole,
and pairing them lets the field take the remaining width instead of
being squeezed by a fixed-width control beside it. -->
<div class="flex items-center gap-1.5">
<div class="relative flex-1">
<SearchIcon class="absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="Filter notes…" bind:value={filter} class="h-7 pl-7 text-xs" />
</div>
<Button
size="sm"
variant="outline"
class="size-7 shrink-0 p-0"
onclick={onNew}
title="New note"
aria-label="New note"
>
<PlusIcon class="size-3.5" />
</Button>
</div>
<!-- Group-by reads as a caption for the tree rather than a third boxed
input: it labels how the list below is arranged, so it's styled like
the group headers it governs (same muted 11px) and only reveals itself
as a control on hover. Its own row of chrome was competing with the
search field for attention while doing far less work. -->
<Select.Root type="single" value={groupBy} onValueChange={setGroupBy}>
<Select.Trigger
size="sm"
class="h-auto w-fit gap-1 rounded border-0 bg-transparent px-1 py-0.5 text-[11px] font-normal tracking-wide text-muted-foreground uppercase shadow-none hover:bg-muted/40 hover:text-foreground focus-visible:ring-0 data-[size=sm]:h-auto dark:bg-transparent dark:hover:bg-muted/40"
title="Change how notes are grouped"
>
<FolderTreeIcon class="size-3 opacity-70" />
by {GROUP_LABELS[groupBy]}
</Select.Trigger>
<Select.Content>
{#each Object.entries(GROUP_LABELS) as [key, label] (key)}
<Select.Item value={key} {label}>{label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<div class="-mx-1 min-h-0 flex-1 overflow-y-auto px-1">
{#each groups as group (group.key)}
{@const isOpen = openGroups.has(group.key)}
<Collapsible.Root open={isOpen} onOpenChange={() => toggleGroup(group.key)}>
<Collapsible.Trigger
class="group/grp flex w-full cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left select-none hover:bg-muted/40"
>
<ChevronRightIcon
class="size-3 shrink-0 text-muted-foreground transition-transform duration-150 {isOpen
? 'rotate-90'
: ''}"
/>
<span
class="min-w-0 flex-1 truncate text-[11px] font-medium tracking-wide text-muted-foreground uppercase group-hover/grp:text-foreground"
>{group.label}</span
>
<span class="shrink-0 text-[10px] tabular-nums text-muted-foreground/70"
>{group.items.length}</span
>
</Collapsible.Trigger>
<Collapsible.Content>
<!-- The guide rule sits inside the indent rather than on each row so
it reads as one continuous line down the group. -->
<div class="mb-1 ml-[13px] flex flex-col border-l border-border/60 pl-1.5">
{#each group.items as it (it.slug + group.key)}
{@const selected = selectedSlug === it.slug}
{@const Icon = kindMeta(it.kind).icon}
<button
type="button"
title={it.title}
class="relative flex items-center gap-2 rounded-md py-1.5 pr-1.5 pl-2 text-left text-xs transition-colors {selected
? 'bg-primary/10 font-medium text-primary'
: 'hover:bg-muted/50'}"
onclick={() => onSelect(it.slug)}
>
<!-- Selection also gets an accent bar on the guide rule: the
background tint alone is easy to lose against the window's
own surface at this size. -->
{#if selected}
<span
class="absolute top-1 bottom-1 -left-[7px] w-[2px] rounded-full bg-primary"
aria-hidden="true"
></span>
{/if}
<Icon
class="size-3.5 shrink-0 {selected ? 'text-primary' : 'text-muted-foreground/70'}"
/>
<span class="min-w-0 flex-1 truncate">{it.title}</span>
{#if isAgentAuthored(it.edited_by)}
<BotIcon
class="size-3 shrink-0 {selected
? 'text-primary/70'
: 'text-muted-foreground/50'}"
/>
{/if}
</button>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{:else}
<p class="px-2 py-8 text-center text-xs text-muted-foreground">
No notes match &ldquo;{filter}&rdquo;.
</p>
{/each}
</div>
</div>

View File

@@ -0,0 +1,50 @@
// Per-kind presentation, shared by the tree, the overview, and anywhere else
// a note's kind needs to be shown at a glance.
//
// Kind is encoded by **icon shape**, not colour. Three categories would be a
// categorical palette, and at the 12px mark size the tree uses, colour alone
// is the least reliable channel there is — it fails for colour-vision
// deficiency, and small low-chroma marks on a dark surface are hard for
// anyone to tell apart. Distinct silhouettes are legible at any size, in any
// theme, for every reader. Colour is left to carry state (selection, the
// agent badge), where it isn't the only thing distinguishing two items.
//
// It also fixes a plain redundancy: the tree previously stamped a literal
// "document" text badge on every row, which in a folder of 20 documents is
// 20 repetitions of the same word and no information at all.
import FileTextIcon from '@lucide/svelte/icons/file-text'
import MicroscopeIcon from '@lucide/svelte/icons/microscope'
import ListChecksIcon from '@lucide/svelte/icons/list-checks'
import type { Component } from 'svelte'
export type NoteKind = 'document' | 'investigation' | 'runbook'
export interface KindMeta {
icon: Component
label: string
/** Plural, for counts and section headings. */
plural: string
}
const FALLBACK: KindMeta = { icon: FileTextIcon, label: 'note', plural: 'notes' }
const KIND_META: Record<NoteKind, KindMeta> = {
document: { icon: FileTextIcon, label: 'document', plural: 'documents' },
investigation: { icon: MicroscopeIcon, label: 'investigation', plural: 'investigations' },
runbook: { icon: ListChecksIcon, label: 'runbook', plural: 'runbooks' }
}
// Tolerates an unknown kind rather than throwing — `kind` comes from the
// entity's type column, which the ontology could grow a fourth value for
// without this file knowing.
export function kindMeta(kind: string): KindMeta {
return KIND_META[kind as NoteKind] ?? FALLBACK
}
// True for notes last written by the agent rather than a human. Two spellings
// exist in the live data: 'nomos-agent' (written via the MCP upsert_knowledge
// tool) and 'agent:mcp' (the actor label the HTTP API records when the same
// agent calls in over REST with the MCP bearer token).
export function isAgentAuthored(editedBy: string): boolean {
return editedBy === 'nomos-agent' || editedBy === 'agent:mcp'
}

View File

@@ -0,0 +1,197 @@
// Shared text helpers for the knowledge wiki (Knowledge.svelte and its
// components). Markdown rendering, slug auto-linking, folder/grouping
// derivation, and a small line diff for the History view — split out from
// any one component since WikiReader and WikiContextRail both need the
// rendering/linking half, and WikiTree needs the grouping half.
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import type { KnowledgeListItem } from '$lib/api'
// Matches a bare entity/knowledge slug like `lxc:gitea` or
// `document:containers/101-jellyfin` — real examples pulled straight from
// the data (`grep`-confirmed: operators and Nomos both write bare slugs
// throughout note bodies today). The `[[wiki-link]]` bracket syntax some
// wikis use was considered and dropped: only one note in the live DB
// contains "[[" at all, and it's an HTML comment, not a link — building
// bracket-syntax parsing would add real complexity (nesting, alias syntax,
// double-substitution risk with this very regex) for a feature nobody
// writes.
//
// Anchored to start with a lowercase letter specifically to reject
// clock-times like "10:08" or "20:40" that are common in this dataset's
// investigation titles/bodies (digits don't match `[a-z]`) and to reject
// "https://..." (the char after ':' there is '/', which fails the
// alnum-first requirement on the right-hand side).
const SLUG_PATTERN = `\\b([a-z][a-z0-9-]{1,30}:[a-zA-Z0-9][a-zA-Z0-9\\-/._]*)\\b`
// A fenced code block (```...```, across lines) or an inline code span
// (`...`, single line) OR a bare slug — tried in that order at every
// position. Fenced/inline code always wins the match when present, so a
// slug-shaped token *inside* a code example (a runbook's shell snippet
// referencing e.g. `host:strong/some-path`) is consumed whole as code and
// never reaches the slug branch. Without this, linkifySlugs ran the slug
// regex over raw markdown with no idea code existed, rewrote the slug
// inside the span to `[slug](#kb:slug)`, and `marked` then rendered that
// literal bracket/paren syntax as text inside the <code> tag instead of
// treating it as code. Doesn't handle every markdown code-span edge case
// (double-backtick escaping for spans containing a literal backtick, `~~~`
// fences) — just the two forms actually used in this corpus.
const TOKEN_PATTERN = new RegExp('(```[\\s\\S]*?```)|(`[^`\\n]+`)|(' + SLUG_PATTERN + ')', 'g')
// Wraps every bare slug in `text` with a placeholder markdown link
// (`[slug](#kb:slug)`) before it reaches `marked`, so the renderer emits a
// real `<a>` that the reader's click handler (see WikiReader.svelte) can
// intercept. The `#kb:` prefix is never a real anchor on this page — it's
// just a tag so the click handler can tell "one of ours" apart from a
// legitimate external link without inspecting every href.
//
// Trailing punctuation immediately after a slug (a period ending a
// sentence, a comma, a closing paren) is peeled off and left outside the
// link — "see host:strong." must not swallow the sentence's full stop into
// the link target.
function linkifySlugs(text: string): string {
return text.replace(TOKEN_PATTERN, (match, fence, inlineCode) => {
if (fence || inlineCode) return match // code — leave untouched, see TOKEN_PATTERN's comment
const trailing = match.match(/[.,;:)]+$/)?.[0] ?? ''
const slug = trailing ? match.slice(0, -trailing.length) : match
if (!slug.includes(':')) return match // shouldn't happen given the pattern, but stay safe
return `[${slug}](#kb:${encodeURIComponent(slug)})${trailing}`
})
}
// Full markdown render for the reader pane: linkify first (plain text, so
// the regex never sees HTML), then render, then sanitize. Mirrors
// EntityDetailContent.svelte's renderMarkdown (marked + DOMPurify, no tag
// restriction) rather than Knowledge.svelte's old snippet-only sanitize
// (which allowlisted only `<b>` for ts_headline output) — this renders a
// full note body, not a search snippet.
export function renderWikiMarkdown(text: string): string {
const linked = linkifySlugs(text)
return DOMPurify.sanitize(marked.parse(linked, { async: false }) as string)
}
// Parses a `#kb:<encoded-slug>` href back into the slug, or null if `href`
// isn't one of ours (a real external/relative link the browser should
// handle normally).
export function slugFromKbHref(href: string | null): string | null {
if (!href || !href.startsWith('#kb:')) return null
try {
return decodeURIComponent(href.slice('#kb:'.length))
} catch {
return null
}
}
// ─── Grouping (navigator tree) ─────────────────────────────────────────────
export type GroupBy = 'folder' | 'kind' | 'tag' | 'entity'
const UNGROUPED = '(ungrouped)'
// The slug format is `<kind>:<folder>/<name>` for namespaced notes (agent
// and seeded content) or plain `<kind>:<name>` for the flat runbooks
// (runbook:lifecycle-activate-node). The latter has no folder segment, so
// it groups under UNGROUPED rather than being silently dropped.
export function noteFolder(item: KnowledgeListItem): string {
const afterColon = item.slug.slice(item.slug.indexOf(':') + 1)
const idx = afterColon.lastIndexOf('/')
return idx === -1 ? UNGROUPED : afterColon.slice(0, idx)
}
export interface WikiGroup {
key: string
label: string
items: KnowledgeListItem[]
}
// Groups `items` by the chosen dimension. `tag` and `entity` are
// many-to-many — a note with three tags appears in three groups — which is
// deliberate: those two modes are for "show me everything touching X," not
// a strict partition like folder/kind are.
export function groupNotes(items: KnowledgeListItem[], by: GroupBy): WikiGroup[] {
const groups = new Map<string, KnowledgeListItem[]>()
const push = (key: string, item: KnowledgeListItem) => {
const arr = groups.get(key)
if (arr) arr.push(item)
else groups.set(key, [item])
}
for (const item of items) {
switch (by) {
case 'folder':
push(noteFolder(item), item)
break
case 'kind':
push(item.kind, item)
break
case 'tag':
if (item.tags.length === 0) push(UNGROUPED, item)
else for (const t of item.tags) push(t, item)
break
case 'entity':
if (item.about.length === 0) push(UNGROUPED, item)
else for (const slug of item.about) push(slug, item)
break
}
}
const out: WikiGroup[] = [...groups.entries()].map(([key, groupItems]) => ({
key,
label: key,
items: groupItems.sort((a, b) => a.title.localeCompare(b.title))
}))
// Ungrouped/misc always last; otherwise alphabetical, largest-first ties
// broken by label so the ordering is stable across reloads.
out.sort((a, b) => {
if (a.key === UNGROUPED) return 1
if (b.key === UNGROUPED) return -1
return a.label.localeCompare(b.label)
})
return out
}
// ─── Line diff (History tab) ───────────────────────────────────────────────
export type DiffOp = { type: 'equal' | 'add' | 'remove'; line: string }
// Textbook O(n*m) LCS-based line diff. Notes in this system are small
// (the seed data averages ~1KB, agent-written investigations rarely exceed
// 2KB, so a few dozen lines at most) — the quadratic cost is invisible at
// this size and a full Myers-diff dependency would be a lot of code for a
// feature that only needs to render a readable before/after in the History
// tab, not power a merge tool.
export function diffLines(oldText: string, newText: string): DiffOp[] {
const a = oldText.split('\n')
const b = newText.split('\n')
const n = a.length
const m = b.length
// lcs[i][j] = length of the LCS of a[i:] and b[j:]
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0))
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1])
}
}
const ops: DiffOp[] = []
let i = 0
let j = 0
while (i < n && j < m) {
if (a[i] === b[j]) {
ops.push({ type: 'equal', line: a[i] })
i++
j++
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
ops.push({ type: 'remove', line: a[i] })
i++
} else {
ops.push({ type: 'add', line: b[j] })
j++
}
}
while (i < n) ops.push({ type: 'remove', line: a[i++] })
while (j < m) ops.push({ type: 'add', line: b[j++] })
return ops
}

View File

@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants'
export const badgeVariants = tv({
base: 'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none',
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive:
'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline'
}
},
defaultVariants: {
variant: 'default'
}
})
export type BadgeVariant = VariantProps<typeof badgeVariants>['variant']
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from 'svelte/elements'
import { cn, type WithElementRef } from '$lib/utils.js'
let {
ref = $bindable(null),
href,
class: className,
variant = 'default',
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant
} = $props()
</script>
<svelte:element
this={href ? 'a' : 'span'}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from './badge.svelte'
export { badgeVariants, type BadgeVariant } from './badge.svelte'

View File

@@ -0,0 +1,89 @@
<script lang="ts" module>
import { cn, type WithElementRef } from '$lib/utils.js'
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'
import { type VariantProps, tv } from 'tailwind-variants'
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
destructive:
'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default:
'h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5',
lg: 'h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-9',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
'icon-sm':
'size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md',
'icon-lg': 'size-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
})
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant']
export type ButtonSize = VariantProps<typeof buttonVariants>['size']
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant
size?: ButtonSize
}
</script>
<script lang="ts">
let {
class: className,
variant = 'default',
size = 'default',
ref = $bindable(null),
href = undefined,
type = 'button',
disabled,
children,
...restProps
}: ButtonProps = $props()
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

Some files were not shown because too many files have changed in this diff Show More