oikos-web: extract the client stack from dtoro/oikos
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.
4
web/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
build/
|
||||
package-lock.json
|
||||
10
web/.prettierrc.json
Normal 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
@@ -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
@@ -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
@@ -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
59
web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
web/public/android-chrome-192.png
Normal file
BIN
web/public/android-chrome-512.png
Normal file
BIN
web/public/apple-touch-icon.png
Normal file
BIN
web/public/favicon.png
Normal file
4
web/public/favicon.svg
Normal 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 |
BIN
web/public/fonts/JetBrainsMono-Bold.woff2
Normal file
BIN
web/public/fonts/JetBrainsMono-Regular.woff2
Normal file
BIN
web/public/fonts/VT323-Regular.woff2
Normal file
20
web/public/mascot/LICENSE-eggs.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
Eggs
|
||||
By Onocentaur
|
||||
https://onocentaur.itch.io
|
||||
March 2021
|
||||
|
||||
Description
|
||||
350+ pixel art eggs for your next virtual pet/match 3/farming/holiday themed game.
|
||||
|
||||
This pack contains:
|
||||
⁃ Over 350 16x16px eggs.
|
||||
⁃ 32 egg designs, 11 color variants.
|
||||
⁃ 2 different cracking animations for each egg.
|
||||
⁃ Spritesheets for each color variant and cracking pattern.
|
||||
⁃ Transparent PNGs.
|
||||
⁃ Template files so you can color your own eggs.
|
||||
⁃ Bonus: Letter & Number eggs.
|
||||
⁃ Bonus: Incubator assets (nesting box and toggle-able lamp).
|
||||
⁃ Bonus: 12 Animal assets.
|
||||
|
||||
Free to use for personal & professional projects. Attribution appreciated. If you use these assets in your project, let me know! I look forward to seeing what you make.
|
||||
5
web/public/mascot/LICENSE.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
CC0 1.0 Universal (CC0 1.0) Public Domain Dedication
|
||||
|
||||
The person who associated a work with this deed has dedicated the work to the public domain by waiving all of his or her rights to the work worldwide under copyright law, including all related and neighboring rights, to the extent allowed by law.
|
||||
You can copy, modify, distribute and perform the work, even for commercial purposes, all without asking permission.
|
||||
BIN
web/public/mascot/blink.png
Normal file
|
After Width: | Height: | Size: 343 B |
BIN
web/public/mascot/egg-crack.png
Normal file
|
After Width: | Height: | Size: 167 B |
BIN
web/public/mascot/egg-idle.png
Normal file
|
After Width: | Height: | Size: 132 B |
BIN
web/public/mascot/egg-shell.png
Normal file
|
After Width: | Height: | Size: 112 B |
BIN
web/public/mascot/hurt.png
Normal file
|
After Width: | Height: | Size: 416 B |
BIN
web/public/mascot/idle.png
Normal file
|
After Width: | Height: | Size: 368 B |
BIN
web/public/mascot/jump.png
Normal file
|
After Width: | Height: | Size: 335 B |
BIN
web/public/mascot/peck.png
Normal file
|
After Width: | Height: | Size: 382 B |
BIN
web/public/mascot/peep.png
Normal file
|
After Width: | Height: | Size: 295 B |
BIN
web/public/mascot/react-displeased.png
Normal file
|
After Width: | Height: | Size: 330 B |
BIN
web/public/mascot/react-joy.png
Normal file
|
After Width: | Height: | Size: 379 B |
BIN
web/public/mascot/react-sigh.png
Normal file
|
After Width: | Height: | Size: 365 B |
BIN
web/public/mascot/react-surprise.png
Normal file
|
After Width: | Height: | Size: 361 B |
BIN
web/public/mascot/react-yell.png
Normal file
|
After Width: | Height: | Size: 346 B |
BIN
web/public/mascot/sleep.png
Normal file
|
After Width: | Height: | Size: 331 B |
BIN
web/public/mascot/walk.png
Normal file
|
After Width: | Height: | Size: 375 B |
BIN
web/public/mascot/walk2.png
Normal file
|
After Width: | Height: | Size: 390 B |
66
web/src/App.svelte
Normal 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
@@ -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
57
web/src/lib/app-store/apps/Notes.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
// Notes — a trivial installable app demoing the App Store lifecycle.
|
||||
// Installed from the App Store, gets a desktop icon, opens in a window,
|
||||
// has its own localStorage-backed state, and uninstalls cleanly. No
|
||||
// shell-internal imports — this is a self-contained app that could be
|
||||
// shipped as a standalone bundle (Phase 4 will load such bundles from
|
||||
// a URL; here it's bundled and discovered via the catalog).
|
||||
let { storageKey = 'oikos-app-notes' }: { storageKey?: string } = $props()
|
||||
|
||||
let text = $state('')
|
||||
let saved = $state(false)
|
||||
|
||||
function load(): string {
|
||||
if (typeof localStorage === 'undefined') return ''
|
||||
return localStorage.getItem(storageKey) ?? ''
|
||||
}
|
||||
function save(): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
localStorage.setItem(storageKey, text)
|
||||
saved = true
|
||||
setTimeout(() => (saved = false), 1500)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
text = load()
|
||||
$effect(() => {
|
||||
if (!text) return
|
||||
const t = setTimeout(() => save(), 2000)
|
||||
return () => clearTimeout(t)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col gap-2 p-4">
|
||||
<div class="flex shrink-0 items-center justify-between">
|
||||
<h2 class="text-sm font-medium">Notes</h2>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{#if saved}saved{:else}unsaved{/if}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
bind:value={text}
|
||||
onkeydown={onKeydown}
|
||||
placeholder="Type here. Auto-saves 2s after you stop, or Cmd/Ctrl+S."
|
||||
class="min-h-0 flex-1 resize-none rounded-md border bg-background p-3 font-mono text-sm leading-relaxed focus-visible:outline-2 focus-visible:outline-ring"
|
||||
></textarea>
|
||||
<p class="shrink-0 text-xs text-muted-foreground">
|
||||
A demo installable app — uninstall it from the App Store to remove its icon and window. Its
|
||||
notes persist in localStorage under
|
||||
<code class="font-mono">{storageKey}</code>.
|
||||
</p>
|
||||
</div>
|
||||
81
web/src/lib/app-store/catalog.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// App Store — installable app catalog + manifest format.
|
||||
//
|
||||
// This is Phase 3's "frontend scaffold, local bundles only" path: a static
|
||||
// catalog of apps that ship with the build, each described by a persistable
|
||||
// manifest (metadata) and resolved at runtime to a loader + icon (runtime
|
||||
// bits that are NOT persisted — they're looked up from the catalog by
|
||||
// manifest id on load). Installing an app = persisting its manifest id;
|
||||
// uninstalling = removing it. The mechanism generalizes to remote bundles
|
||||
// in Phase 4 by swapping the catalog for a fetched manifest + a
|
||||
// `import(/* @vite-ignore */ entryUrl)` loader.
|
||||
//
|
||||
// Permissions are DECLARED on the manifest but NOT YET ENFORCED — that's
|
||||
// Phase 4 (sandboxing). They're part of the contract now so a manifest
|
||||
// author has to name what the app needs, and the operator can see it in
|
||||
// the App Store before installing. Enforcement will land at the AppOS
|
||||
// boundary (docs/mbse/components.md §9 "OS-service surface") in Phase 4.
|
||||
import type { Component } from 'svelte'
|
||||
import NotesIcon from '@lucide/svelte/icons/sticky-note'
|
||||
|
||||
// A permission an installable app can request. Maps 1:1 to entries in the
|
||||
// AppOS table (docs/mbse/components.md §9). Phase 4 will enforce these at
|
||||
// the store-access boundary; today they're declaration-only.
|
||||
export type AppPermission =
|
||||
| 'open-window' // openAppWindow / openEntityWindow / openTaskWindow
|
||||
| 'read-context' // dashboard summary, subscribeContext
|
||||
| 'read-events' // subscribeEvents (SSE)
|
||||
| 'api:entities' // $lib/api entity endpoints
|
||||
| 'api:knowledge' // knowledge search/content
|
||||
| 'api:executions' // executions/approvals
|
||||
| 'theme' // getTheme / setTheme
|
||||
|
||||
// Persistable metadata describing an installable app. This is what's
|
||||
// stored in localStorage when an app is installed (just the manifest id is
|
||||
// persisted, actually — the manifest is re-resolved from the catalog on
|
||||
// load — but the shape is the unit of interchange and will be what a
|
||||
// remote `/api/v1/apps` endpoint returns in Phase 4).
|
||||
export interface AppManifest {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
version: string
|
||||
author?: string
|
||||
permissions: AppPermission[]
|
||||
docked?: boolean
|
||||
noIcon?: boolean
|
||||
width?: number
|
||||
height?: number
|
||||
minWidth?: number
|
||||
minHeight?: number
|
||||
}
|
||||
|
||||
// A catalog entry: the manifest (persistable metadata) plus the runtime
|
||||
// bits the catalog resolves by id — the Lucide icon component and the
|
||||
// dynamic-import loader. These runtime bits are never persisted; they're
|
||||
// re-looked-up from this static catalog on every load.
|
||||
export interface CatalogEntry {
|
||||
manifest: AppManifest
|
||||
icon: Component
|
||||
load: () => Promise<{ default: Component }>
|
||||
}
|
||||
|
||||
export const CATALOG: CatalogEntry[] = [
|
||||
{
|
||||
manifest: {
|
||||
id: 'notes',
|
||||
title: 'Notes',
|
||||
description: 'A scratchpad. Auto-saves to localStorage. Demo installable app.',
|
||||
version: '0.1.0',
|
||||
author: 'oikos',
|
||||
permissions: ['theme'],
|
||||
width: 640,
|
||||
height: 480,
|
||||
minWidth: 360,
|
||||
minHeight: 320
|
||||
},
|
||||
icon: NotesIcon,
|
||||
load: () => import('./apps/Notes.svelte')
|
||||
}
|
||||
]
|
||||
|
||||
export const catalogById = new Map(CATALOG.map((e) => [e.manifest.id, e]))
|
||||
135
web/src/lib/apps.test.ts
Normal 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
@@ -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
|
||||
}
|
||||
954
web/src/lib/components/ChatThread.svelte
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
return `<div class="code-block-wrapper relative group"><pre><code class="language-${lang || 'plaintext'}">${escaped}</code></pre><button class="code-copy-btn" onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)" title="Copy" aria-label="Copy code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>`
|
||||
}
|
||||
renderer.table = function (token) {
|
||||
const header = token.header.map((c: { text: string }) => `<th>${c.text}</th>`).join('')
|
||||
const body = token.rows
|
||||
.map((r: { text: string }[]) => `<tr>${r.map((c) => `<td>${c.text}</td>`).join('')}</tr>`)
|
||||
.join('')
|
||||
return `<div class="table-wrapper"><table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table></div>`
|
||||
}
|
||||
return DOMPurify.sanitize(marked.parse(text, { async: false, renderer }) as string)
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function submit() {
|
||||
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>
|
||||
170
web/src/lib/components/ConfigBackground.svelte
Normal 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>
|
||||
43
web/src/lib/components/DetailSection.svelte
Normal 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>
|
||||
20
web/src/lib/components/EmptyState.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
message = 'No items.',
|
||||
colspan = 999,
|
||||
class: className
|
||||
}: {
|
||||
message?: string
|
||||
colspan?: number
|
||||
class?: string
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<tr>
|
||||
<td
|
||||
{colspan}
|
||||
class={['py-8 text-center text-muted-foreground', className].filter(Boolean).join(' ')}
|
||||
>
|
||||
{message}
|
||||
</td>
|
||||
</tr>
|
||||
1188
web/src/lib/components/EntityDetailContent.svelte
Normal file
258
web/src/lib/components/EntityTable.svelte
Normal 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}
|
||||
42
web/src/lib/components/FilterTabs.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import * as Tabs from '$lib/components/ui/tabs'
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
tabs,
|
||||
class: className,
|
||||
children
|
||||
}: {
|
||||
value?: string
|
||||
tabs: {
|
||||
value: string
|
||||
label: string
|
||||
count?: number
|
||||
variant?: 'destructive' | 'default' | 'secondary' | 'outline'
|
||||
}[]
|
||||
class?: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children?: any
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<Tabs.Root
|
||||
bind:value
|
||||
class={['flex flex-1 flex-col overflow-hidden', className].filter(Boolean).join(' ')}
|
||||
>
|
||||
<Tabs.List>
|
||||
{#each tabs as tab}
|
||||
<Tabs.Trigger value={tab.value}>
|
||||
{tab.label}
|
||||
{#if tab.count != null && tab.count > 0}
|
||||
<slot name="badge-{tab.value}">
|
||||
<!-- slot for custom badge rendering -->
|
||||
</slot>
|
||||
{/if}
|
||||
</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</Tabs.Root>
|
||||
1311
web/src/lib/components/FleetMap.svelte
Normal file
89
web/src/lib/components/GlyphIndicator.svelte
Normal 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>
|
||||
89
web/src/lib/components/OperatorQuestion.svelte
Normal 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}
|
||||
206
web/src/lib/components/RasterImage.svelte
Normal 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 Floyd–Steinberg.
|
||||
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>
|
||||
119
web/src/lib/components/SessionChatWindow.svelte
Normal 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>
|
||||
650
web/src/lib/components/SessionGraph.svelte
Normal 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>
|
||||
34
web/src/lib/components/Spinner.svelte
Normal 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>
|
||||
50
web/src/lib/components/StatusBadge.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
|
||||
type StatusKind = 'risk' | 'severity' | 'execution' | 'type' | 'default'
|
||||
|
||||
let {
|
||||
value,
|
||||
kind = 'default',
|
||||
class: className
|
||||
}: {
|
||||
value: string
|
||||
kind?: StatusKind
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
const variantMap: Record<
|
||||
StatusKind,
|
||||
Record<string, 'default' | 'secondary' | 'destructive' | 'outline'>
|
||||
> = {
|
||||
risk: {
|
||||
destructive: 'destructive',
|
||||
config_mutation: 'secondary'
|
||||
},
|
||||
severity: {
|
||||
critical: 'destructive',
|
||||
warning: 'secondary',
|
||||
info: 'default'
|
||||
},
|
||||
execution: {
|
||||
failed: 'destructive',
|
||||
denied: 'destructive',
|
||||
revoked: 'destructive',
|
||||
cancelled: 'destructive',
|
||||
completed: 'default',
|
||||
running: 'secondary',
|
||||
approved: 'secondary'
|
||||
},
|
||||
type: {
|
||||
runbook: 'secondary',
|
||||
investigation: 'default'
|
||||
},
|
||||
default: {}
|
||||
}
|
||||
|
||||
function variant(): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
return variantMap[kind]?.[value] ?? (kind === 'default' ? 'default' : 'outline')
|
||||
}
|
||||
</script>
|
||||
|
||||
<Badge variant={variant()} class={className}>{value}</Badge>
|
||||
65
web/src/lib/components/TaskContextPanel.svelte
Normal 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>
|
||||
106
web/src/lib/components/ThinkingBlock.svelte
Normal 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>
|
||||
258
web/src/lib/components/ToolLine.svelte
Normal 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>
|
||||
325
web/src/lib/components/TurnTrace.svelte
Normal 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>
|
||||
242
web/src/lib/components/data-table/DataTable.svelte
Normal file
@@ -0,0 +1,242 @@
|
||||
<script lang="ts">
|
||||
import { TableHandler } from '@vincjo/datatables'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import SortHeader from './SortHeader.svelte'
|
||||
import Toolbar from './Toolbar.svelte'
|
||||
import Pagination from './pagination/Pagination.svelte'
|
||||
import EmptyState from '$lib/components/EmptyState.svelte'
|
||||
import BadgeRenderer from './renderers/BadgeRenderer.svelte'
|
||||
import HealthDotRenderer from './renderers/HealthDotRenderer.svelte'
|
||||
import RelativeTimeRenderer from './renderers/RelativeTimeRenderer.svelte'
|
||||
import DateRenderer from './renderers/DateRenderer.svelte'
|
||||
import StatusBadgeRenderer from './renderers/StatusBadgeRenderer.svelte'
|
||||
import { resolveCellValue } from './columns'
|
||||
import type { DataTableColumn, BuiltinRenderer } from './types'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Row = Record<string, any>
|
||||
|
||||
const renderers: Record<string, unknown> = {
|
||||
badge: BadgeRenderer,
|
||||
'health-dot': HealthDotRenderer,
|
||||
'relative-time': RelativeTimeRenderer,
|
||||
date: DateRenderer,
|
||||
'status-badge': StatusBadgeRenderer
|
||||
}
|
||||
|
||||
let {
|
||||
columns,
|
||||
data = [],
|
||||
pageSize = 20,
|
||||
paginated = false,
|
||||
searchable = false,
|
||||
bordered = true,
|
||||
loading = false,
|
||||
emptyMessage = 'No items.',
|
||||
selected = $bindable(null),
|
||||
onRowClick = undefined,
|
||||
class: className,
|
||||
children
|
||||
}: {
|
||||
columns: DataTableColumn<Row>[]
|
||||
data: Row[]
|
||||
pageSize?: number
|
||||
paginated?: boolean
|
||||
searchable?: boolean
|
||||
bordered?: boolean
|
||||
loading?: boolean
|
||||
emptyMessage?: string
|
||||
selected?: string | null
|
||||
onRowClick?: (row: Row) => void
|
||||
class?: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children?: any
|
||||
} = $props()
|
||||
|
||||
const table = new TableHandler([], { pageSize: 20 })
|
||||
|
||||
// One SortBuilder per sortable column — each tracks its own direction/isActive
|
||||
// via $derived runes internally.
|
||||
const sortBuilders = new Map<string, ReturnType<typeof table.createSort>>()
|
||||
|
||||
function getSortBuilder(col: DataTableColumn<Row>) {
|
||||
if (!sortBuilders.has(col.key)) {
|
||||
sortBuilders.set(col.key, table.createSort(col.accessor ?? col.key))
|
||||
}
|
||||
return sortBuilders.get(col.key)!
|
||||
}
|
||||
|
||||
let search = $state.raw(
|
||||
table.createSearch({
|
||||
filterFunction: (row: Row, q: string) => {
|
||||
if (!q) return true
|
||||
const lower = q.toLowerCase()
|
||||
for (const col of columns) {
|
||||
if (col.hidden) continue
|
||||
const val = String(resolveCellValue(row, col) ?? '').toLowerCase()
|
||||
if (val.includes(lower)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
table.setRowsPerPage(pageSize)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
table.setRows(data)
|
||||
})
|
||||
|
||||
function handleSearch(q: string) {
|
||||
search.set(q)
|
||||
if (paginated) table.setPage(1)
|
||||
}
|
||||
|
||||
function colAlignClass(col: DataTableColumn<Row>): string {
|
||||
if (col.align === 'right') return 'text-right'
|
||||
if (col.align === 'center') return 'text-center'
|
||||
return ''
|
||||
}
|
||||
|
||||
function colTruncateClass(col: DataTableColumn<Row>): string {
|
||||
return col.truncate ? 'min-w-0 overflow-hidden text-ellipsis' : ''
|
||||
}
|
||||
|
||||
function colStyle(col: DataTableColumn<Row>): string | undefined {
|
||||
if (!col.width) return undefined
|
||||
const w = typeof col.width === 'number' ? col.width + 'px' : col.width
|
||||
return `width: ${w}; min-width: ${w}`
|
||||
}
|
||||
|
||||
const visibleCols = $derived(columns.filter((c) => !c.hidden))
|
||||
const rows = $derived(table.rows as Row[])
|
||||
|
||||
const skeletonWidths = ['w-24', 'w-20', 'w-28', 'w-16', 'w-24', 'w-20', 'w-28', 'w-16']
|
||||
</script>
|
||||
|
||||
<div class={['flex flex-col h-full min-h-0', className].filter(Boolean).join(' ')}>
|
||||
<Toolbar {table} {searchable} {paginated} onSearchChange={handleSearch} {children} />
|
||||
|
||||
<div
|
||||
class={['flex flex-col min-h-0 flex-1', bordered ? 'rounded-xl border' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<table class="w-full caption-bottom text-sm table-fixed">
|
||||
<thead class="[&_tr]:border-b">
|
||||
<tr>
|
||||
{#each visibleCols as col (col.key)}
|
||||
<th
|
||||
class={[
|
||||
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap',
|
||||
'bg-card/95',
|
||||
col.headerClass,
|
||||
colAlignClass(col)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={colStyle(col)}
|
||||
>
|
||||
{#if col.sortable !== false}
|
||||
{@const sb = getSortBuilder(col)}
|
||||
<SortHeader
|
||||
label={col.header}
|
||||
sorted={sb.isActive}
|
||||
direction={sb.direction ?? 'asc'}
|
||||
onclick={() => sb.set()}
|
||||
/>
|
||||
{:else}
|
||||
{col.header}
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<table class="w-full caption-bottom text-sm table-fixed">
|
||||
<tbody class="[&_tr:last-child]:border-0">
|
||||
{#if loading}
|
||||
{#each skeletonWidths as w, i}
|
||||
<tr class="border-b transition-colors hover:bg-transparent">
|
||||
{#each visibleCols as col (col.key)}
|
||||
<td
|
||||
class={[col.class, colAlignClass(col), colTruncateClass(col)]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={colStyle(col)}
|
||||
>
|
||||
<Skeleton
|
||||
class="h-4 {skeletonWidths[
|
||||
(i + visibleCols.indexOf(col)) % skeletonWidths.length
|
||||
]}"
|
||||
/>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{:else if rows.length === 0}
|
||||
<EmptyState message={emptyMessage} colspan={visibleCols.length} />
|
||||
{:else}
|
||||
{#each rows as row, idx (row.id ?? row.slug ?? `row-${idx}`)}
|
||||
<tr
|
||||
class={[
|
||||
'border-b transition-colors hover:bg-muted/50',
|
||||
onRowClick ? 'cursor-pointer' : '',
|
||||
selected === (row.id ?? row.slug) ? 'bg-muted' : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
tabindex={onRowClick ? 0 : undefined}
|
||||
onclick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
onkeydown={onRowClick
|
||||
? (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onRowClick(row)
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{#each visibleCols as col (col.key)}
|
||||
{@const val = resolveCellValue(row, col)}
|
||||
<td
|
||||
class={[
|
||||
'p-2 align-middle whitespace-nowrap',
|
||||
col.class,
|
||||
colAlignClass(col),
|
||||
colTruncateClass(col)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={colStyle(col)}
|
||||
>
|
||||
{#if typeof col.render === 'string'}
|
||||
{@const R = renderers[col.render]}
|
||||
{#if R}
|
||||
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
|
||||
<R value={val} {row} {...col.renderProps ?? {}} />
|
||||
{:else}
|
||||
{String(val ?? '—')}
|
||||
{/if}
|
||||
{:else if typeof col.render === 'function'}
|
||||
<col.render {row} value={val} {...col.renderProps ?? {}} />
|
||||
{:else}
|
||||
{String(val ?? '—')}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if paginated}
|
||||
<Pagination {table} />
|
||||
{/if}
|
||||
</div>
|
||||
55
web/src/lib/components/data-table/SearchInput.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import SearchIcon from '@lucide/svelte/icons/search'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import { debounce } from '$lib/utils'
|
||||
|
||||
let {
|
||||
value = '',
|
||||
placeholder = 'Search...',
|
||||
class: className,
|
||||
onSearch
|
||||
}: {
|
||||
value?: string
|
||||
placeholder?: string
|
||||
class?: string
|
||||
onSearch?: (q: string) => void
|
||||
} = $props()
|
||||
|
||||
let inputVal = $state('')
|
||||
|
||||
const debouncedSearch = debounce((q: string) => {
|
||||
onSearch?.(q)
|
||||
}, 200)
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
inputVal = target.value
|
||||
debouncedSearch(inputVal)
|
||||
}
|
||||
|
||||
function clear() {
|
||||
inputVal = ''
|
||||
onSearch?.('')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={['relative', className].filter(Boolean).join(' ')}>
|
||||
<SearchIcon class="absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
{placeholder}
|
||||
value={inputVal}
|
||||
oninput={handleInput}
|
||||
class="h-8 pl-8 pr-8 text-xs"
|
||||
/>
|
||||
{#if inputVal}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onclick={clear}
|
||||
>
|
||||
<XIcon class="size-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
30
web/src/lib/components/data-table/SortHeader.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down'
|
||||
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down'
|
||||
|
||||
let {
|
||||
label,
|
||||
sorted = false,
|
||||
direction = 'asc',
|
||||
onclick
|
||||
}: {
|
||||
label: string
|
||||
sorted?: boolean
|
||||
direction?: 'asc' | 'desc'
|
||||
onclick?: () => void
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<button type="button" class="flex items-center gap-1 hover:text-foreground" {onclick}>
|
||||
{label}
|
||||
{#if sorted}
|
||||
{#if direction === 'asc'}
|
||||
<ArrowUpIcon class="size-3" />
|
||||
{:else}
|
||||
<ArrowDownIcon class="size-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDownIcon class="size-3 text-muted-foreground/50" />
|
||||
{/if}
|
||||
</button>
|
||||
33
web/src/lib/components/data-table/Toolbar.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import SearchInput from './SearchInput.svelte'
|
||||
import RowsPerPage from './pagination/RowsPerPage.svelte'
|
||||
import type { TableHandler } from '@vincjo/datatables'
|
||||
|
||||
let {
|
||||
table,
|
||||
searchable = false,
|
||||
paginated = false,
|
||||
onSearchChange,
|
||||
children
|
||||
}: {
|
||||
table: TableHandler<Record<string, unknown>>
|
||||
searchable?: boolean
|
||||
paginated?: boolean
|
||||
onSearchChange?: (q: string) => void
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children?: any
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
{#if searchable || paginated || children}
|
||||
<div class="flex items-center gap-2 px-1 py-2">
|
||||
{#if searchable}
|
||||
<SearchInput placeholder="Search..." onSearch={onSearchChange} class="w-64" />
|
||||
{/if}
|
||||
<div class="flex-1"></div>
|
||||
{@render children?.()}
|
||||
{#if paginated}
|
||||
<RowsPerPage {table} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
9
web/src/lib/components/data-table/columns.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { DataTableColumn } from './types'
|
||||
|
||||
export function resolveCellValue<T>(row: T, col: DataTableColumn<T>): unknown {
|
||||
if (col.accessor) return col.accessor(row)
|
||||
if (col.key in (row as Record<string, unknown>)) {
|
||||
return (row as Record<string, unknown>)[col.key]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import type { ButtonSize } from '$lib/components/ui/button'
|
||||
|
||||
let {
|
||||
page,
|
||||
active,
|
||||
disabled = false,
|
||||
size = 'xs' as ButtonSize,
|
||||
onclick
|
||||
}: {
|
||||
page: number | string
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
size?: ButtonSize
|
||||
onclick?: () => void
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<Button {size} variant={active ? 'default' : 'outline'} {disabled} {onclick}>
|
||||
{String(page)}
|
||||
</Button>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||
import PageButton from './PageButton.svelte'
|
||||
import type { TableHandler } from '@vincjo/datatables'
|
||||
|
||||
let { table }: { table: TableHandler<Record<string, unknown>> } = $props()
|
||||
|
||||
const pages = $derived(table.pagesWithEllipsis as (number | '...')[])
|
||||
const currentPage = $derived(table.currentPage)
|
||||
const pageCount = $derived(table.pageCount)
|
||||
const rowCount = $derived(table.rowCount)
|
||||
</script>
|
||||
|
||||
{#if pageCount > 1}
|
||||
<div class="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||
<span class="text-xs text-muted-foreground">{rowCount} rows</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<PageButton
|
||||
page={ChevronLeftIcon}
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => table.setPage('previous')}
|
||||
/>
|
||||
{#each pages as page}
|
||||
{#if page === '...'}
|
||||
<span class="px-1 text-xs text-muted-foreground">…</span>
|
||||
{:else}
|
||||
<PageButton
|
||||
{page}
|
||||
active={page === currentPage}
|
||||
onclick={() => table.setPage(page as number)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
<PageButton
|
||||
page={ChevronRightIcon}
|
||||
disabled={currentPage === pageCount}
|
||||
onclick={() => table.setPage('next')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '$lib/components/ui/select'
|
||||
import type { TableHandler } from '@vincjo/datatables'
|
||||
|
||||
let {
|
||||
table,
|
||||
class: className
|
||||
}: { table: TableHandler<Record<string, unknown>>; class?: string } = $props()
|
||||
|
||||
const options = [10, 20, 50, 100]
|
||||
let value = $state('20')
|
||||
|
||||
function handleChange(newValue: string | undefined) {
|
||||
if (!newValue) return
|
||||
value = newValue
|
||||
table.setRowsPerPage(parseInt(newValue))
|
||||
}
|
||||
</script>
|
||||
|
||||
<Select.Root type="single" {value} onValueChange={handleChange}>
|
||||
<Select.Trigger size="sm" class={className}>
|
||||
{value}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each options as n}
|
||||
<Select.Item value={String(n)}>{n} / page</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { ActivityItem } from '$lib/api'
|
||||
|
||||
let { row }: { row: ActivityItem } = $props()
|
||||
|
||||
function fmtDuration(ms: number | null): string {
|
||||
if (ms == null) return '—'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
const s = Math.round(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div>{row.verb}</div>
|
||||
{#if row.summary}
|
||||
<div class="text-xs text-muted-foreground">{row.summary}</div>
|
||||
{/if}
|
||||
{#if row.error}
|
||||
<div class="text-xs text-destructive">{row.error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import type { ActivityItem } from '$lib/api'
|
||||
|
||||
let {
|
||||
row,
|
||||
onCancel
|
||||
}: {
|
||||
row: ActivityItem
|
||||
onCancel?: (id: string) => void
|
||||
} = $props()
|
||||
|
||||
function showCancel(status: string): boolean {
|
||||
return ['pending_approval', 'approved', 'running'].includes(status)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justify-end">
|
||||
{#if showCancel(row.status)}
|
||||
<Button size="sm" variant="outline" onclick={() => onCancel?.(row.id)}>Cancel</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import type { Approval } from '$lib/api'
|
||||
|
||||
let {
|
||||
row,
|
||||
deciding = null,
|
||||
onApprove,
|
||||
onDeny
|
||||
}: {
|
||||
row: Approval
|
||||
deciding?: string | null
|
||||
onApprove?: (id: string) => void
|
||||
onDeny?: (id: string) => void
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button size="sm" disabled={deciding === row.id} onclick={() => onApprove?.(row.id)}
|
||||
>Approve</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={deciding === row.id}
|
||||
onclick={() => onDeny?.(row.id)}>Deny</Button
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Badge, type BadgeVariant } from '$lib/components/ui/badge'
|
||||
|
||||
let { value, variant = 'outline' as BadgeVariant }: { value: unknown; variant?: BadgeVariant } =
|
||||
$props()
|
||||
</script>
|
||||
|
||||
<Badge {variant}>{String(value ?? '—')}</Badge>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
let { value }: { value: unknown } = $props()
|
||||
|
||||
function format(val: unknown): string {
|
||||
if (!val) return '—'
|
||||
try {
|
||||
return new Date(String(val)).toLocaleString()
|
||||
} catch {
|
||||
return String(val)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<span class="text-xs text-muted-foreground">{format(value)}</span>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { ActivityItem } from '$lib/api'
|
||||
|
||||
let { value }: { value: unknown } = $props()
|
||||
|
||||
function fmtDuration(ms: number | null): string {
|
||||
if (ms == null) return '—'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
const s = Math.round(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<span class="text-xs text-muted-foreground">{fmtDuration(value as number | null)}</span>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import type { Entity } from '$lib/api'
|
||||
|
||||
let { row, value }: { row: Entity; value: unknown } = $props()
|
||||
|
||||
const dot: Record<string, string> = {
|
||||
healthy: 'bg-success',
|
||||
degraded: 'bg-warning',
|
||||
down: 'bg-destructive',
|
||||
stale: 'bg-warning/50',
|
||||
unknown: 'bg-muted-foreground/40'
|
||||
}
|
||||
|
||||
const health = $derived(row.health)
|
||||
const lastCheck = $derived(row.last_check_at)
|
||||
|
||||
const title = $derived.by(() => {
|
||||
if (!row.health) return 'not monitored'
|
||||
if (row.health === 'stale') return `stale — last checked ${relativeTime(row.last_check_at)}`
|
||||
return `${row.health} — checked ${relativeTime(row.last_check_at)}`
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if health}
|
||||
<span class="flex items-center gap-1.5 text-xs" {title}>
|
||||
<span class="size-2 shrink-0 rounded-full {dot[row.health ?? ''] ?? ''}"></span>
|
||||
<span class="text-muted-foreground">{relativeTime(lastCheck)}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">—</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { relativeTime } from '$lib/utils'
|
||||
|
||||
let { value }: { value: unknown } = $props()
|
||||
</script>
|
||||
|
||||
<span class="text-xs text-muted-foreground">{relativeTime(String(value ?? ''))}</span>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import type { Signal } from '$lib/api'
|
||||
|
||||
let {
|
||||
row,
|
||||
acting = null,
|
||||
onAck,
|
||||
onMute,
|
||||
onResolve
|
||||
}: {
|
||||
row: Signal
|
||||
acting?: string | null
|
||||
onAck?: (id: string) => void
|
||||
onMute?: (id: string) => void
|
||||
onResolve?: (id: string) => void
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
{#if row.state === 'raised'}
|
||||
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onAck?.(row.id)}
|
||||
>Ack</Button
|
||||
>
|
||||
{/if}
|
||||
<Button size="sm" variant="outline" disabled={acting === row.id} onclick={() => onMute?.(row.id)}
|
||||
>Mute 1h</Button
|
||||
>
|
||||
<Button size="sm" disabled={acting === row.id} onclick={() => onResolve?.(row.id)}>Resolve</Button
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
|
||||
let {
|
||||
value,
|
||||
kind = 'default'
|
||||
}: { value: unknown; kind?: 'risk' | 'severity' | 'execution' | 'state' | 'type' | 'default' } =
|
||||
$props()
|
||||
|
||||
const v = $derived(String(value ?? ''))
|
||||
|
||||
const variantMap: Record<
|
||||
string,
|
||||
Record<string, 'default' | 'secondary' | 'destructive' | 'outline'>
|
||||
> = {
|
||||
risk: {
|
||||
destructive: 'destructive',
|
||||
config_mutation: 'secondary',
|
||||
default: 'default'
|
||||
},
|
||||
severity: {
|
||||
critical: 'destructive',
|
||||
warning: 'secondary',
|
||||
info: 'default',
|
||||
default: 'default'
|
||||
},
|
||||
execution: {
|
||||
failed: 'destructive',
|
||||
denied: 'destructive',
|
||||
revoked: 'destructive',
|
||||
cancelled: 'destructive',
|
||||
completed: 'default',
|
||||
running: 'secondary',
|
||||
approved: 'secondary',
|
||||
default: 'outline'
|
||||
},
|
||||
state: {
|
||||
active: 'default',
|
||||
healthy: 'default',
|
||||
default: 'outline'
|
||||
},
|
||||
type: {
|
||||
runbook: 'secondary',
|
||||
investigation: 'default',
|
||||
default: 'outline'
|
||||
},
|
||||
default: { default: 'default' }
|
||||
}
|
||||
|
||||
const variant = $derived.by(() => {
|
||||
const map = variantMap[kind] ?? variantMap.default
|
||||
return (map[v] ?? map.default) as 'default' | 'secondary' | 'destructive' | 'outline'
|
||||
})
|
||||
</script>
|
||||
|
||||
<Badge {variant}>{v}</Badge>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { statusStyle } from '$lib/tasks'
|
||||
import type { Session } from '$lib/api'
|
||||
|
||||
let { row }: { row: Session } = $props()
|
||||
|
||||
const st = $derived(statusStyle(row))
|
||||
</script>
|
||||
|
||||
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
|
||||
{st.label}
|
||||
</span>
|
||||
36
web/src/lib/components/data-table/types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ComponentType, SvelteComponent } from 'svelte'
|
||||
|
||||
export type BuiltinRenderer = 'badge' | 'health-dot' | 'relative-time' | 'date' | 'status-badge'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type CellComponent = ComponentType<SvelteComponent<{ row: any; value: unknown }>>
|
||||
|
||||
export interface DataTableColumn<T> {
|
||||
key: string
|
||||
header: string
|
||||
sortable?: boolean
|
||||
width?: string | number
|
||||
align?: 'left' | 'right' | 'center'
|
||||
truncate?: boolean
|
||||
class?: string
|
||||
headerClass?: string
|
||||
render?: BuiltinRenderer | CellComponent
|
||||
renderProps?: Record<string, unknown>
|
||||
accessor?: (row: T) => unknown
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
columns: DataTableColumn<T>[]
|
||||
data: T[]
|
||||
pageSize?: number
|
||||
paginated?: boolean
|
||||
searchable?: boolean
|
||||
loading?: boolean
|
||||
emptyMessage?: string
|
||||
selected?: string[]
|
||||
onRowClick?: (row: T) => void
|
||||
class?: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children?: any
|
||||
}
|
||||
152
web/src/lib/components/desktop-shell/Desktop.svelte
Normal 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>
|
||||
109
web/src/lib/components/desktop-shell/DesktopIcon.svelte
Normal 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>
|
||||
24
web/src/lib/components/desktop-shell/DockedLayer.svelte
Normal 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>
|
||||
37
web/src/lib/components/desktop-shell/LazyApp.svelte
Normal 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}
|
||||
35
web/src/lib/components/desktop-shell/NewTaskChat.svelte
Normal 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={() => {}}
|
||||
/>
|
||||
63
web/src/lib/components/desktop-shell/TaskLauncher.svelte
Normal 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>
|
||||
133
web/src/lib/components/desktop-shell/Taskbar.svelte
Normal 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>
|
||||
121
web/src/lib/components/desktop-shell/WindowLayer.svelte
Normal 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>
|
||||
626
web/src/lib/components/knowledge/WikiCleanup.svelte
Normal 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 "{m.title}" 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 "{m.title}" 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>
|
||||
124
web/src/lib/components/knowledge/WikiContextRail.svelte
Normal 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>
|
||||
139
web/src/lib/components/knowledge/WikiNewDialog.svelte
Normal 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>
|
||||
201
web/src/lib/components/knowledge/WikiOverview.svelte
Normal 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>
|
||||
119
web/src/lib/components/knowledge/WikiQuickOpen.svelte
Normal 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>
|
||||
485
web/src/lib/components/knowledge/WikiReader.svelte
Normal 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>
|
||||
198
web/src/lib/components/knowledge/WikiTree.svelte
Normal 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 “{filter}”.
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
50
web/src/lib/components/knowledge/kinds.ts
Normal 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'
|
||||
}
|
||||
197
web/src/lib/components/knowledge/wikiText.ts
Normal 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
|
||||
}
|
||||
50
web/src/lib/components/ui/badge/badge.svelte
Normal 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>
|
||||
2
web/src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Badge } from './badge.svelte'
|
||||
export { badgeVariants, type BadgeVariant } from './badge.svelte'
|
||||
89
web/src/lib/components/ui/button/button.svelte
Normal 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}
|
||||