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.
36
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
# oikos-web CI (Gitea Actions) — SPA pipeline, mirrors the web job this
|
||||
# repo inherited from dtoro/oikos.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- run: npm ci
|
||||
- name: svelte-check (advisory — baseline not yet clean)
|
||||
run: npm run check
|
||||
continue-on-error: true
|
||||
- name: eslint (advisory — baseline not yet clean)
|
||||
run: npm run lint
|
||||
continue-on-error: true
|
||||
- name: prettier format check (advisory — baseline not yet clean)
|
||||
run: npm run format:check
|
||||
continue-on-error: true
|
||||
- name: test
|
||||
run: npm run test
|
||||
- name: build
|
||||
run: npm run build
|
||||
70
.gitea/workflows/desktop.yml
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Desktop App
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'desktop-*'
|
||||
- 'v[0-9]+.[0-9]+.[0-9]*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Linux (amd64)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- run: npm ci
|
||||
working-directory: web
|
||||
|
||||
- run: npm run build
|
||||
working-directory: web
|
||||
|
||||
- run: |
|
||||
rm -rf desktop/frontend/dist
|
||||
mkdir -p desktop/frontend/dist
|
||||
cp -r web/dist/* desktop/frontend/dist/
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26'
|
||||
|
||||
- run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
- run: CGO_ENABLED=1 go build -o build/bin/Oikos .
|
||||
working-directory: desktop
|
||||
|
||||
- run: |
|
||||
cd desktop/build/bin
|
||||
tar czf oikos-desktop-linux-amd64.tar.gz Oikos
|
||||
sha256sum oikos-desktop-linux-amd64.tar.gz > oikos-desktop-linux-amd64.tar.gz.sha256
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: oikos-desktop-linux-amd64
|
||||
path: |
|
||||
desktop/build/bin/oikos-desktop-linux-amd64.tar.gz
|
||||
desktop/build/bin/oikos-desktop-linux-amd64.tar.gz.sha256
|
||||
|
||||
release:
|
||||
name: Attach to Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: oikos-desktop-linux-amd64
|
||||
|
||||
- uses: https://gitea.com/actions/release-action@v1
|
||||
with:
|
||||
files: |
|
||||
oikos-desktop-linux-amd64.tar.gz
|
||||
oikos-desktop-linux-amd64.tar.gz.sha256
|
||||
api_key: ${{ secrets.GITEA_TOKEN }}
|
||||
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
.DS_Store
|
||||
|
||||
# Binaries
|
||||
webhook
|
||||
oikos-desktop
|
||||
|
||||
.env
|
||||
|
||||
# Web SPA build artifacts
|
||||
web/dist/
|
||||
web/node_modules/
|
||||
|
||||
# Wails desktop app — frontend copy for embedding, build output
|
||||
desktop/frontend/dist/
|
||||
desktop/build/
|
||||
desktop/Oikos
|
||||
40
Makefile
Normal file
@@ -0,0 +1,40 @@
|
||||
# oikos-web — control-room SPA + desktop app delivery
|
||||
#
|
||||
# Extracted from dtoro/oikos (plans/2026-08-15-hexagonal-architecture.md
|
||||
# Phase 1). The backend API/MCP stack stays in dtoro/oikos; this repo owns
|
||||
# everything that renders it.
|
||||
|
||||
.PHONY: ui desktop desktop-package install webhook clean
|
||||
|
||||
# Local sanity-check build of the SPA.
|
||||
ui:
|
||||
cd web && npm run build
|
||||
|
||||
desktop: ui ## Build the Wails desktop app for the current platform
|
||||
rm -rf desktop/frontend/dist
|
||||
mkdir -p desktop/frontend/dist
|
||||
cp -r web/dist/* desktop/frontend/dist/
|
||||
cd desktop && CGO_ENABLED=1 go build -tags desktop -ldflags "-X main.version=$$(cat ../VERSION)" -o build/bin/Oikos .
|
||||
|
||||
desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar.gz on Linux)
|
||||
@if [ "$(shell uname)" = "Darwin" ]; then \
|
||||
mkdir -p desktop/build/bin/Oikos.app/Contents/MacOS desktop/build/bin/Oikos.app/Contents/Resources; \
|
||||
APP="desktop/build/bin/Oikos.app"; \
|
||||
cp desktop/build/bin/Oikos "$$APP/Contents/MacOS/Oikos"; \
|
||||
cp desktop/icon.icns "$$APP/Contents/Resources/icon.icns"; \
|
||||
sed "s/\$$(VERSION)/$$(cat VERSION)/" desktop/Info.plist.template > "$$APP/Contents/Info.plist"; \
|
||||
cd desktop/build/bin && zip -r oikos-desktop-darwin-$$(uname -m).zip Oikos.app ;; \
|
||||
else \
|
||||
cd desktop/build/bin && tar czf oikos-desktop-linux-$$(uname -m).tar.gz Oikos ;; \
|
||||
fi
|
||||
@echo "Package: desktop/build/bin/"
|
||||
|
||||
install: desktop-package ## Install to /Applications (macOS)
|
||||
rm -rf /Applications/Oikos.app
|
||||
cp -r desktop/build/bin/Oikos.app /Applications/
|
||||
|
||||
webhook: ## Build the deploy-webhook receiver
|
||||
go build -o webhook ./cmd/webhook
|
||||
|
||||
clean:
|
||||
rm -rf web/dist desktop/build desktop/frontend/dist webhook
|
||||
50
README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# oikos-web
|
||||
|
||||
The Oikos control-room client: the Svelte 5 SPA (`web/`), the Wails v3
|
||||
desktop wrapper (`desktop/`), and the delivery stack that serves it
|
||||
(`compose/`, `docker-compose.yml`, `scripts/deploy.sh`).
|
||||
|
||||
Extracted from [dtoro/oikos](https://git.hubris.network/dtoro/oikos) in
|
||||
Phase 1 of the hexagonal-architecture refactor
|
||||
(plans/2026-08-15-hexagonal-architecture.md in that repo). The backend
|
||||
(API, MCP, scheduler) stays in dtoro/oikos — this repo talks to it over
|
||||
REST/SSE only.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
web/ Control-room SPA (Svelte 5, Vite)
|
||||
desktop/ Wails v3 desktop wrapper (macOS + Linux)
|
||||
compose/ Dockerfile + Caddyfile for the oikos-web image
|
||||
cmd/webhook/ Deploy-webhook receiver (Gitea push → deploy)
|
||||
scripts/deploy.sh Deploy script (CI-green gate, versioned images)
|
||||
docker-compose.yml The single `web` service, publishing 8091:80
|
||||
```
|
||||
|
||||
## Dev
|
||||
|
||||
```bash
|
||||
cd web && OIKOS_API_TOKEN=dev-token npm run dev # proxies to api :8090/:8092
|
||||
make desktop # build the desktop app
|
||||
```
|
||||
|
||||
The SPA's first-launch Config screen stores the server URL + token; the
|
||||
backend version is available via the API ping — the sidebar shows this
|
||||
repo's VERSION.
|
||||
|
||||
## Versioning
|
||||
|
||||
Own `VERSION` file with the same rule as oikos: every commit to `main`
|
||||
bumps it (patch for fixes, minor for features). The version tag is shown
|
||||
in the SPA sidebar and stamped into desktop builds.
|
||||
|
||||
## Deploy
|
||||
|
||||
Push to `main` → Gitea webhook → `cmd/webhook` receiver →
|
||||
`scripts/deploy.sh`: waits for CI green, builds `oikos-web:v$VERSION` from
|
||||
the working tree, `docker compose up -d`, prunes to the 3 newest tags.
|
||||
Rollback: `OIKOS_VERSION=v0.x.y docker compose up -d`.
|
||||
|
||||
Desktop auto-update reads this repo's Gitea releases. Builds installed
|
||||
before the extraction point at dtoro/oikos and need one manual reinstall
|
||||
(see the first release notes).
|
||||
27
compose/Caddyfile
Normal file
@@ -0,0 +1,27 @@
|
||||
:80 {
|
||||
root * /srv
|
||||
|
||||
# /wails/runtime.js is injected by the Wails desktop wrapper, which serves
|
||||
# the same dist/ from its own asset handler. In a browser it does not
|
||||
# exist, and the SPA fallback below answered it with index.html — so the
|
||||
# browser parsed "<!doctype html>" as JavaScript and threw
|
||||
# "SyntaxError: expected expression, got '<'" on every page load.
|
||||
# Return a real 404 instead: the tag fails quietly, and the desktop app is
|
||||
# unaffected because it never reaches this server.
|
||||
handle /wails/* {
|
||||
error 404
|
||||
}
|
||||
|
||||
# Same reasoning for any other asset: a missing .js/.css/.map answered with
|
||||
# HTML is always a confusing parse error rather than an honest 404. Only
|
||||
# real routes should fall through to the SPA.
|
||||
@asset path_regexp \.(js|mjs|css|map|json|png|jpg|svg|ico|woff2?)$
|
||||
handle @asset {
|
||||
file_server
|
||||
}
|
||||
|
||||
handle {
|
||||
file_server
|
||||
try_files {path} /index.html
|
||||
}
|
||||
}
|
||||
20
compose/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
# Dockerfile for the oikos control-room SPA. Built separately from the
|
||||
# oikos binary (compose/oikos/Dockerfile) — see docker-compose.yml's `web`
|
||||
# service. The outer production Caddy (caddy-conf repo, LXC 121) handles
|
||||
# Authentik + splits /api/*, /mcp, /agent/* off to the api service; this
|
||||
# container only serves static files with SPA-fallback routing.
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
COPY web/vendor /build/vendor
|
||||
RUN npm install --no-audit --no-fund
|
||||
COPY VERSION ./
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM caddy:2-alpine
|
||||
|
||||
COPY --from=builder /build/web/dist /srv
|
||||
COPY compose/web/Caddyfile /etc/caddy/Caddyfile
|
||||
30
desktop/Info.plist.template
Normal file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Oikos</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.hubris.oikos-desktop</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Oikos</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(VERSION)</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>13.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2026 Hubris. All rights reserved.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
14
desktop/Taskfile.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
version: '3'
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: Build the Oikos desktop app
|
||||
cmds:
|
||||
- go build -o build/bin/Oikos .
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
dev:
|
||||
summary: Run in development mode
|
||||
cmds:
|
||||
- go run .
|
||||
12
desktop/assets_embed.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build desktop
|
||||
|
||||
package main
|
||||
|
||||
import "embed"
|
||||
|
||||
// assets is the embedded web SPA. Built only with the `desktop` tag, which is
|
||||
// set by `make desktop` after it copies web/dist/* into cmd/desktop/frontend/dist
|
||||
// (a gitignored build artifact). See assets_stub.go for the default build.
|
||||
//
|
||||
//go:embed frontend/dist
|
||||
var assets embed.FS
|
||||
12
desktop/assets_stub.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build !desktop
|
||||
|
||||
package main
|
||||
|
||||
import "embed"
|
||||
|
||||
// assets is an empty FS for the default (non-desktop) build. The real embedded
|
||||
// SPA lives in assets_embed.go behind the `desktop` build tag, because
|
||||
// frontend/dist is a gitignored artifact that only exists after `make desktop`
|
||||
// copies web/dist/* into it. This stub lets `go build ./...` compile cleanly on
|
||||
// a fresh checkout without the frontend built.
|
||||
var assets embed.FS
|
||||
26
desktop/entitlements.plist
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<false/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<false/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<false/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.hubris.oikos-desktop</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
desktop/icon.icns
Normal file
BIN
desktop/icon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
790
desktop/main.go
Normal file
@@ -0,0 +1,790 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
_ "embed" // required by the //go:embed icon.png directive below
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// assets is the embedded web SPA, defined in assets_embed.go (`desktop` build
|
||||
// tag, real //go:embed frontend/dist) and assets_stub.go (default build, empty
|
||||
// FS). frontend/dist is a gitignored artifact populated by `make desktop`; the
|
||||
// stub keeps `go build ./...` working on a clean checkout.
|
||||
|
||||
//go:embed icon.png
|
||||
var iconPNG []byte
|
||||
|
||||
const (
|
||||
keyringService = "com.hubris.oikos-desktop"
|
||||
keyringUser = "oikos"
|
||||
updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos-web/releases"
|
||||
pollInterval = 30 * time.Second
|
||||
updateInterval = 6 * time.Hour
|
||||
oidcCallbackPort = 18901
|
||||
)
|
||||
|
||||
// version is injected at link time via -ldflags "-X main.version=$(cat VERSION)"
|
||||
// (Makefile desktop target). The default keeps a non-empty fallback for
|
||||
// `go build ./cmd/desktop` without ldflags.
|
||||
var version = "0.1.0-dev"
|
||||
|
||||
type OikosConfig struct {
|
||||
ApiUrl string `json:"apiUrl"`
|
||||
Token string `json:"token,omitempty"`
|
||||
IsDesktop bool `json:"isDesktop"`
|
||||
}
|
||||
|
||||
// ---- ConfigService ----
|
||||
|
||||
type ConfigService struct{}
|
||||
|
||||
func (c *ConfigService) Name() string { return "config" }
|
||||
|
||||
func (c *ConfigService) SaveConfig(apiUrl, token string) error {
|
||||
cfg := OikosConfig{ApiUrl: apiUrl, Token: token, IsDesktop: true}
|
||||
data, _ := json.Marshal(cfg)
|
||||
return keyring.Set(keyringService, keyringUser, string(data))
|
||||
}
|
||||
|
||||
func (c *ConfigService) ClearConfig() error {
|
||||
return keyring.Delete(keyringService, keyringUser)
|
||||
}
|
||||
|
||||
func (c *ConfigService) GetStoredConfig() *OikosConfig {
|
||||
return loadConfig()
|
||||
}
|
||||
|
||||
func (c *ConfigService) EnableAutoStart() error {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return fmt.Errorf("autostart not supported on %s", runtime.GOOS)
|
||||
}
|
||||
usr, _ := user.Current()
|
||||
dir := filepath.Join(usr.HomeDir, "Library", "LaunchAgents")
|
||||
os.MkdirAll(dir, 0755)
|
||||
|
||||
exe, _ := os.Executable()
|
||||
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.hubris.oikos-desktop</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>%s</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>`, exe)
|
||||
|
||||
return os.WriteFile(filepath.Join(dir, "com.hubris.oikos-desktop.plist"), []byte(plist), 0644)
|
||||
}
|
||||
|
||||
func (c *ConfigService) DisableAutoStart() error {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return fmt.Errorf("autostart not supported on %s", runtime.GOOS)
|
||||
}
|
||||
usr, _ := user.Current()
|
||||
path := filepath.Join(usr.HomeDir, "Library", "LaunchAgents", "com.hubris.oikos-desktop.plist")
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
// ---- Local OIDC server (runs alongside the webview) ----
|
||||
|
||||
type oidcSession struct {
|
||||
apiUrl string
|
||||
verifier string
|
||||
state string
|
||||
ch chan string
|
||||
}
|
||||
|
||||
var (
|
||||
oidcSessionsMu sync.Mutex
|
||||
oidcSessions = make(map[string]*oidcSession)
|
||||
)
|
||||
|
||||
func startOIDCServer() *http.Server {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
cors := func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
h := func(path string, handler func(http.ResponseWriter, *http.Request)) {
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
cors(w, r)
|
||||
if r.Method == "OPTIONS" {
|
||||
return
|
||||
}
|
||||
handler(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
h("/oidc/start", func(w http.ResponseWriter, r *http.Request) {
|
||||
apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
|
||||
returnURL := r.URL.Query().Get("ret")
|
||||
if apiUrl == "" {
|
||||
http.Error(w, "apiUrl required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if returnURL == "" {
|
||||
returnURL = "/?desktop=1"
|
||||
}
|
||||
|
||||
oidcCfg, err := fetchOIDCConfig(apiUrl)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
verifier, challenge, _ := pkceParams()
|
||||
state := randomString(32)
|
||||
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort)
|
||||
|
||||
ch := make(chan string, 1)
|
||||
oidcSessionsMu.Lock()
|
||||
sessionID := randomString(16)
|
||||
oidcSessions[sessionID] = &oidcSession{apiUrl: apiUrl, verifier: verifier, state: state, ch: ch}
|
||||
oidcSessionsMu.Unlock()
|
||||
|
||||
authURL := fmt.Sprintf("%s?%s",
|
||||
oidcCfg.AuthorizationEndpoint,
|
||||
url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {oidcCfg.ClientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
"code_challenge": {challenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
"state": {state},
|
||||
"scope": {"openid profile email"},
|
||||
}.Encode(),
|
||||
)
|
||||
|
||||
exec.Command("open", authURL).Start()
|
||||
|
||||
select {
|
||||
case token := <-ch:
|
||||
if token != "" {
|
||||
c := &ConfigService{}
|
||||
c.SaveConfig(apiUrl, token)
|
||||
returnURL += "&token=" + url.QueryEscape(token)
|
||||
}
|
||||
case <-time.After(5 * time.Minute):
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
|
||||
<meta http-equiv="refresh" content="0;url=%s">
|
||||
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
||||
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
|
||||
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
|
||||
</head><body><div class="card"><h1>Connected</h1><p class="ok">Redirecting back to Oikos…</p></div></body></html>`, returnURL)
|
||||
})
|
||||
|
||||
h("/oidc/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
gotState := r.URL.Query().Get("state")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
oidcSessionsMu.Lock()
|
||||
var session *oidcSession
|
||||
var sessionID string
|
||||
for id, s := range oidcSessions {
|
||||
if s.state == gotState {
|
||||
session = s
|
||||
sessionID = id
|
||||
break
|
||||
}
|
||||
}
|
||||
oidcSessionsMu.Unlock()
|
||||
|
||||
if session == nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("Invalid state."))
|
||||
return
|
||||
}
|
||||
|
||||
token, err := exchangeCode(
|
||||
session.apiUrl,
|
||||
code, session.verifier,
|
||||
fmt.Sprintf("http://127.0.0.1:%d/oidc/callback", oidcCallbackPort),
|
||||
)
|
||||
|
||||
oidcSessionsMu.Lock()
|
||||
delete(oidcSessions, sessionID)
|
||||
oidcSessionsMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, "Token exchange failed: %v", err)
|
||||
session.ch <- ""
|
||||
return
|
||||
}
|
||||
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Oikos</title>
|
||||
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#0a0a0a;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
||||
.card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:32px;max-width:400px;text-align:center}
|
||||
h1{font-size:18px;margin-bottom:8px}.ok{color:#22c55e;font-size:14px}</style>
|
||||
</head><body><div class="card"><h1>Connected</h1><p class="ok">You can close this window and return to Oikos.</p></div></body></html>`))
|
||||
session.ch <- token
|
||||
})
|
||||
|
||||
mux.HandleFunc("/oidc/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
apiUrl := strings.TrimRight(r.URL.Query().Get("apiUrl"), "/")
|
||||
if apiUrl == "" {
|
||||
http.Error(w, "apiUrl required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cfg, err := fetchOIDCConfig(apiUrl)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(cfg)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/update/check", func(w http.ResponseWriter, r *http.Request) {
|
||||
latest := fetchLatestRelease()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
if latest == nil {
|
||||
json.NewEncoder(w).Encode(map[string]string{"current": version})
|
||||
return
|
||||
}
|
||||
hasAsset := false
|
||||
for _, a := range latest.Assets {
|
||||
if strings.Contains(a.Name, "darwin") {
|
||||
hasAsset = true
|
||||
updater.mu.Lock()
|
||||
updater.latestURL = a.BrowserDownloadURL
|
||||
updater.mu.Unlock()
|
||||
break
|
||||
}
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"current": version,
|
||||
"latest": latest.Version,
|
||||
"has_asset": fmt.Sprintf("%t", hasAsset),
|
||||
})
|
||||
})
|
||||
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", oidcCallbackPort))
|
||||
if err != nil {
|
||||
log.Printf("OIDC server: %v", err)
|
||||
return nil
|
||||
}
|
||||
log.Printf("OIDC server listening on %s", listener.Addr())
|
||||
srv := &http.Server{Handler: mux}
|
||||
go srv.Serve(listener)
|
||||
return srv
|
||||
}
|
||||
|
||||
// ---- Window persistence ----
|
||||
|
||||
type windowState struct {
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
func windowStatePath() string {
|
||||
usr, _ := user.Current()
|
||||
return filepath.Join(usr.HomeDir, ".config", "oikos", "window.json")
|
||||
}
|
||||
|
||||
func loadWindowState() *windowState {
|
||||
data, err := os.ReadFile(windowStatePath())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var ws windowState
|
||||
if err := json.Unmarshal(data, &ws); err != nil {
|
||||
return nil
|
||||
}
|
||||
if ws.Width < 200 || ws.Height < 200 {
|
||||
return nil
|
||||
}
|
||||
return &ws
|
||||
}
|
||||
|
||||
func saveWindowState(w application.Window) {
|
||||
x, y := w.Position()
|
||||
width, height := w.Size()
|
||||
ws := windowState{X: x, Y: y, Width: width, Height: height}
|
||||
data, _ := json.Marshal(ws)
|
||||
|
||||
usr, _ := user.Current()
|
||||
dir := filepath.Join(usr.HomeDir, ".config", "oikos")
|
||||
os.MkdirAll(dir, 0755)
|
||||
os.WriteFile(filepath.Join(dir, "window.json"), data, 0644)
|
||||
}
|
||||
|
||||
func loadConfig() *OikosConfig {
|
||||
data, err := keyring.Get(keyringService, keyringUser)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var cfg OikosConfig
|
||||
if err := json.Unmarshal([]byte(data), &cfg); err != nil {
|
||||
return nil
|
||||
}
|
||||
cfg.IsDesktop = true
|
||||
return &cfg
|
||||
}
|
||||
|
||||
type oidcConfig struct {
|
||||
Issuer string `json:"issuer"`
|
||||
ClientID string `json:"client_id"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
}
|
||||
|
||||
func fetchOIDCConfig(apiUrl string) (*oidcConfig, error) {
|
||||
resp, err := http.Get(apiUrl + "/api/v1/auth/oidc-config")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("server returned %d", resp.StatusCode)
|
||||
}
|
||||
var cfg oidcConfig
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func pkceParams() (verifier, challenge string, _ error) {
|
||||
v := randomString(64)
|
||||
h := sha256.Sum256([]byte(v))
|
||||
return v, base64.RawURLEncoding.EncodeToString(h[:]), nil
|
||||
}
|
||||
|
||||
func randomString(n int) string {
|
||||
b := make([]byte, n)
|
||||
rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func exchangeCode(apiUrl, code, verifier, redirectURI string) (string, error) {
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"code_verifier": verifier,
|
||||
"redirect_uri": redirectURI,
|
||||
})
|
||||
|
||||
resp, err := http.Post(apiUrl+"/api/v1/auth/oidc-token", "application/json", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("token endpoint: %d — %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
var tokens struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if tokens.AccessToken == "" {
|
||||
return "", fmt.Errorf("no access_token in response")
|
||||
}
|
||||
return tokens.AccessToken, nil
|
||||
}
|
||||
|
||||
// ---- Notifications ----
|
||||
|
||||
type dashboardSummary struct {
|
||||
ApprovalsPending int `json:"approvals_pending"`
|
||||
Signals struct {
|
||||
Critical int `json:"critical"`
|
||||
} `json:"signals_by_severity"`
|
||||
}
|
||||
|
||||
func (d *dashboardSummary) alertCount() int {
|
||||
return d.ApprovalsPending + d.Signals.Critical
|
||||
}
|
||||
|
||||
func notify(title, subtitle string) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return
|
||||
}
|
||||
script := fmt.Sprintf(
|
||||
`display notification "%s" with title "%s" sound name "default"`,
|
||||
strings.ReplaceAll(subtitle, `"`, `\"`),
|
||||
strings.ReplaceAll(title, `"`, `\"`),
|
||||
)
|
||||
exec.Command("osascript", "-e", script).Run()
|
||||
}
|
||||
|
||||
func pollDashboard(cfg *OikosConfig) {
|
||||
if cfg == nil || cfg.ApiUrl == "" || cfg.Token == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var lastCount int
|
||||
first := true
|
||||
|
||||
for {
|
||||
req, err := http.NewRequest("GET", cfg.ApiUrl+"/api/v1/dashboard/summary", nil)
|
||||
if err != nil {
|
||||
time.Sleep(pollInterval)
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
time.Sleep(pollInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
var summary dashboardSummary
|
||||
if err := json.Unmarshal(body, &summary); err != nil {
|
||||
time.Sleep(pollInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
if first {
|
||||
lastCount = summary.alertCount()
|
||||
first = false
|
||||
} else {
|
||||
current := summary.alertCount()
|
||||
if current > lastCount {
|
||||
notify("Oikos", fmt.Sprintf("%d pending approval(s), %d critical signal(s)", summary.ApprovalsPending, summary.Signals.Critical))
|
||||
}
|
||||
lastCount = current
|
||||
}
|
||||
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Auto-update ----
|
||||
|
||||
type giteaRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
type updateState struct {
|
||||
mu sync.Mutex
|
||||
latestURL string
|
||||
}
|
||||
|
||||
var updater = &updateState{}
|
||||
|
||||
// CheckForUpdates checks Gitea releases for a newer version. If found, stores
|
||||
// the download URL and returns the latest version string (empty if current).
|
||||
func (c *ConfigService) CheckForUpdates() string {
|
||||
latest := fetchLatestRelease()
|
||||
if latest == nil || latest.Version == version {
|
||||
return ""
|
||||
}
|
||||
for _, a := range latest.Assets {
|
||||
if strings.Contains(a.Name, "darwin") {
|
||||
updater.mu.Lock()
|
||||
updater.latestURL = a.BrowserDownloadURL
|
||||
updater.mu.Unlock()
|
||||
return latest.Version
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// InstallUpdate downloads the stored update, replaces the app, and restarts.
|
||||
func (c *ConfigService) InstallUpdate() error {
|
||||
updater.mu.Lock()
|
||||
url := updater.latestURL
|
||||
updater.mu.Unlock()
|
||||
if url == "" {
|
||||
return fmt.Errorf("no update available")
|
||||
}
|
||||
return doUpdate(url)
|
||||
}
|
||||
|
||||
type latestRelease struct {
|
||||
Version string
|
||||
Assets []struct {
|
||||
Name string
|
||||
BrowserDownloadURL string
|
||||
}
|
||||
}
|
||||
|
||||
func fetchLatestRelease() *latestRelease {
|
||||
resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var releases []giteaRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil || len(releases) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
r := releases[0]
|
||||
v := strings.TrimPrefix(r.TagName, "v")
|
||||
if v == version {
|
||||
return nil
|
||||
}
|
||||
|
||||
lr := &latestRelease{Version: v}
|
||||
for _, a := range r.Assets {
|
||||
lr.Assets = append(lr.Assets, struct {
|
||||
Name string
|
||||
BrowserDownloadURL string
|
||||
}{a.Name, a.BrowserDownloadURL})
|
||||
}
|
||||
return lr
|
||||
}
|
||||
|
||||
func doUpdate(downloadURL string) error {
|
||||
tmp, err := os.CreateTemp("", "oikos-update-*.zip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
|
||||
resp, err := http.Get(downloadURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
extractDir, err := os.MkdirTemp("", "oikos-extract")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(extractDir)
|
||||
|
||||
cmd := exec.Command("unzip", "-o", tmp.Name(), "-d", extractDir)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("unzip: %w: %s", err, out)
|
||||
}
|
||||
|
||||
newApp := filepath.Join(extractDir, "Oikos.app")
|
||||
if _, err := os.Stat(newApp); err != nil {
|
||||
return fmt.Errorf("extracted app not found: %w", err)
|
||||
}
|
||||
|
||||
currentApp := "/Applications/Oikos.app"
|
||||
if _, err := os.Stat(currentApp); os.IsNotExist(err) {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
currentApp = filepath.Dir(filepath.Dir(filepath.Dir(exe)))
|
||||
}
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`#!/bin/bash
|
||||
sleep 2
|
||||
rm -rf "%s"
|
||||
mv "%s" "%s"
|
||||
open "%s"
|
||||
rm "$0"
|
||||
`, currentApp, newApp, currentApp, currentApp)
|
||||
|
||||
scriptPath := filepath.Join(os.TempDir(), "oikos-update.sh")
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app := application.Get()
|
||||
exec.Command("open", scriptPath).Start()
|
||||
if app != nil {
|
||||
app.Quit()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkUpdates() {
|
||||
for {
|
||||
time.Sleep(updateInterval)
|
||||
|
||||
latest := fetchLatestRelease()
|
||||
if latest == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, a := range latest.Assets {
|
||||
if strings.Contains(a.Name, "darwin") {
|
||||
updater.mu.Lock()
|
||||
updater.latestURL = a.BrowserDownloadURL
|
||||
updater.mu.Unlock()
|
||||
|
||||
app := application.Get()
|
||||
if app == nil {
|
||||
continue
|
||||
}
|
||||
msg := fmt.Sprintf("Version %s is available (you have %s).", latest.Version, version)
|
||||
app.Dialog.Info().
|
||||
SetTitle("Update Available").
|
||||
SetMessage(msg).
|
||||
Show()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Main ----
|
||||
|
||||
func main() {
|
||||
oidcSrv := startOIDCServer()
|
||||
defer oidcSrv.Close()
|
||||
|
||||
distFS, err := fs.Sub(assets, "frontend/dist")
|
||||
if err != nil {
|
||||
log.Fatalf("embedded assets: %v", err)
|
||||
}
|
||||
|
||||
app := application.New(application.Options{
|
||||
Name: "Oikos",
|
||||
Description: "Homelab Control Room",
|
||||
Services: []application.Service{
|
||||
application.NewService(&ConfigService{}),
|
||||
},
|
||||
Assets: application.AssetOptions{
|
||||
Handler: application.AssetFileServerFS(distFS),
|
||||
},
|
||||
Mac: application.MacOptions{
|
||||
ApplicationShouldTerminateAfterLastWindowClosed: false,
|
||||
},
|
||||
})
|
||||
|
||||
systemTray := app.SystemTray.New()
|
||||
systemTray.SetTooltip("Oikos")
|
||||
systemTray.SetIcon(iconPNG)
|
||||
|
||||
trayMenu := application.NewMenu()
|
||||
trayMenu.Add("Open Oikos").OnClick(func(ctx *application.Context) {
|
||||
for _, w := range app.Window.GetAll() {
|
||||
w.Show()
|
||||
w.Focus()
|
||||
}
|
||||
})
|
||||
trayMenu.AddSeparator()
|
||||
trayMenu.Add("Check for Updates").OnClick(func(ctx *application.Context) {
|
||||
go func() {
|
||||
latest := fetchLatestRelease()
|
||||
if latest == nil {
|
||||
app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show()
|
||||
return
|
||||
}
|
||||
for _, a := range latest.Assets {
|
||||
if strings.Contains(a.Name, "darwin") {
|
||||
updater.mu.Lock()
|
||||
updater.latestURL = a.BrowserDownloadURL
|
||||
updater.mu.Unlock()
|
||||
msg := fmt.Sprintf("Version %s is available (you have %s). Install now?", latest.Version, version)
|
||||
d := app.Dialog.Question().SetTitle("Update Available").SetMessage(msg)
|
||||
yes := d.AddButton("Install")
|
||||
yes.OnClick(func() { doUpdate(updater.latestURL) })
|
||||
no := d.AddButton("Later")
|
||||
d.SetDefaultButton(yes)
|
||||
d.SetCancelButton(no)
|
||||
d.Show()
|
||||
return
|
||||
}
|
||||
}
|
||||
app.Dialog.Info().SetTitle("Up to Date").SetMessage("You are running the latest version (" + version + ").").Show()
|
||||
}()
|
||||
})
|
||||
trayMenu.AddSeparator()
|
||||
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
|
||||
app.Quit()
|
||||
})
|
||||
systemTray.SetMenu(trayMenu)
|
||||
|
||||
ws := loadWindowState()
|
||||
width, height := 1400, 900
|
||||
if ws != nil {
|
||||
width = ws.Width
|
||||
height = ws.Height
|
||||
}
|
||||
|
||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Title: "Oikos",
|
||||
Width: width,
|
||||
Height: height,
|
||||
MinWidth: 1024,
|
||||
MinHeight: 700,
|
||||
URL: "/?desktop=1",
|
||||
})
|
||||
|
||||
if ws != nil {
|
||||
window.SetPosition(ws.X, ws.Y)
|
||||
} else {
|
||||
window.Center()
|
||||
}
|
||||
|
||||
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
||||
window.Hide()
|
||||
e.Cancel()
|
||||
})
|
||||
|
||||
window.Show()
|
||||
|
||||
systemTray.AttachWindow(window)
|
||||
systemTray.Run()
|
||||
|
||||
app.OnShutdown(func() {
|
||||
saveWindowState(window)
|
||||
})
|
||||
|
||||
go pollDashboard(loadConfig())
|
||||
go checkUpdates()
|
||||
|
||||
err = app.Run()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
5
desktop/tray-icon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="88" height="88" viewBox="0 0 110 120" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(9, 10) scale(0.9)">
|
||||
<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"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 690 B |
9
desktop/wails.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "oikos",
|
||||
"outputfilename": "oikos-desktop",
|
||||
"frontend:dir": "frontend",
|
||||
"author": {
|
||||
"name": "Hubris",
|
||||
"email": "d.toro.v@pm.me"
|
||||
}
|
||||
}
|
||||
20
docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
# oikos-web deploy stack — the control-room SPA container.
|
||||
#
|
||||
# Publishes the same host port the oikos stack's `web` service used
|
||||
# (8091:80); the outer production Caddy (caddy-conf repo, LXC 121) targets
|
||||
# this published port, so no shared Docker network with the oikos stack is
|
||||
# needed. Images are version-tagged by scripts/deploy.sh
|
||||
# (oikos-web:v$VERSION); `latest` is only a manual-build fallback.
|
||||
|
||||
services:
|
||||
web:
|
||||
image: oikos-web:${OIKOS_VERSION:-latest}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: compose/Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8091:80"
|
||||
stop_signal: SIGTERM
|
||||
mem_limit: 64m
|
||||
cpus: 0.25
|
||||
20
go.mod
Normal file
@@ -0,0 +1,20 @@
|
||||
module github.com/dtoro/oikos-web
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/adrg/xdg v0.5.3 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
)
|
||||
41
go.sum
Normal file
@@ -0,0 +1,41 @@
|
||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
223
scripts/deploy.sh
Executable file
@@ -0,0 +1,223 @@
|
||||
#!/bin/sh
|
||||
# oikos-web deploy script — triggered by Gitea webhook on push to dtoro/oikos-web.
|
||||
# Runs on mac-mini as non-root user via launchd unit oikos-web-deploy-webhook.service.
|
||||
#
|
||||
# Mirrors the essentials of dtoro/oikos's scripts/deploy.sh (plans D1/D2 there):
|
||||
# D1 — CI gate: blocks deploy unless Gitea reports a green run for the SHA.
|
||||
# D2 — versioned images: tags the image v$VERSION (from VERSION file),
|
||||
# keeps the last 3 tags for rollback.
|
||||
# No pg_dump / seed steps — this stack serves static files only.
|
||||
|
||||
# Notify on deploy failure. Uses the Oikos API to raise an event so the
|
||||
# scheduler picks it up (best-effort, silent failure).
|
||||
notify_deploy_failure() {
|
||||
local reason="$1"
|
||||
local sha="${SHA:-unknown}"
|
||||
echo "NOTIFY: deploy failed — $reason"
|
||||
if [ -n "${OIKOS_API_TOKEN:-}" ]; then
|
||||
curl -sf -X POST "http://localhost:8090/api/v1/events" \
|
||||
-H "Authorization: Bearer $OIKOS_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"type\":\"deploy.failed\",\"severity\":\"critical\",\"source\":\"webhook\",\"data\":{\"repo\":\"oikos-web\",\"sha\":\"$sha\",\"reason\":\"$reason\"}}" \
|
||||
>/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
set -e
|
||||
|
||||
REPO_DIR="${REPO_DIR:-$PWD}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
||||
HEALTH_URL="${HEALTH_URL:-http://localhost:8091/}"
|
||||
RETRIES=${RETRIES:-30}
|
||||
SLEEP=${SLEEP:-2}
|
||||
|
||||
# CI gate (D1). Set GITEA_URL + GITEA_TOKEN to enable; without them the gate
|
||||
# is skipped with a warning (dev/local builds).
|
||||
GITEA_URL="${GITEA_URL:-}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
GITEA_OWNER="${GITEA_OWNER:-dtoro}"
|
||||
GITEA_REPO="${GITEA_REPO:-oikos-web}"
|
||||
CI_POLL_INTERVAL="${CI_POLL_INTERVAL:-15}"
|
||||
CI_TIMEOUT="${CI_TIMEOUT:-1200}"
|
||||
|
||||
# Serialize deploys (mkdir lock — atomic on POSIX, no flock on macOS).
|
||||
LOCKDIR="${LOCKDIR:-/tmp/oikos-web-deploy.lock}"
|
||||
if ! mkdir "$LOCKDIR" 2>/dev/null; then
|
||||
oldpid=$(cat "$LOCKDIR/pid" 2>/dev/null || echo "")
|
||||
if [ -n "$oldpid" ] && kill -0 "$oldpid" 2>/dev/null; then
|
||||
echo "deploy already in progress (pid $oldpid) — exiting"
|
||||
exit 0
|
||||
fi
|
||||
echo "removing stale deploy lock (pid ${oldpid:-?} not running)"
|
||||
rm -rf "$LOCKDIR"
|
||||
mkdir "$LOCKDIR"
|
||||
fi
|
||||
echo $$ > "$LOCKDIR/pid"
|
||||
trap 'rc=$?; rm -rf "$LOCKDIR" 2>/dev/null || true; if [ "$_ok" != "1" ]; then notify_deploy_failure "deploy aborted (exit $rc)"; fi' EXIT
|
||||
_ok=0
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
echo "=== oikos-web deploy: $(date) ==="
|
||||
|
||||
# Resolve the SHA we are ABOUT to deploy from the remote (read-only) so the
|
||||
# CI gate can run before anything is touched.
|
||||
REMOTE_FULL=$(git ls-remote origin refs/heads/main 2>/dev/null | awk '{print $1}')
|
||||
if [ -z "$REMOTE_FULL" ]; then
|
||||
echo "ERROR: could not resolve origin/main (offline?) — aborting before any change"
|
||||
exit 1
|
||||
fi
|
||||
REMOTE_SHA=$(printf '%s' "$REMOTE_FULL" | cut -c1-12)
|
||||
echo "remote SHA: $REMOTE_SHA"
|
||||
|
||||
# ── 1. CI gate (D1) ──────────────────────────────────────────────────────
|
||||
echo "[1/6] verify CI status for $REMOTE_SHA"
|
||||
verify_ci() {
|
||||
sha=$1
|
||||
if [ -z "$GITEA_URL" ] || [ -z "$GITEA_TOKEN" ]; then
|
||||
echo "SKIP: GITEA_URL/GITEA_TOKEN not set — CI gate disabled. Set both to enforce."
|
||||
return 0
|
||||
fi
|
||||
origin=$(git remote get-url origin 2>/dev/null || echo "")
|
||||
seg=
|
||||
case "$origin" in
|
||||
*@*:*) seg=${origin##*:}; seg=${seg%.git} ;;
|
||||
http://*|https://*) seg=${origin#*://}; seg=${seg#*/}; seg=${seg%.git} ;;
|
||||
esac
|
||||
case "$seg" in
|
||||
*/*) GITEA_OWNER=${seg%%/*}; GITEA_REPO=${seg#*/} ;;
|
||||
esac
|
||||
|
||||
api="$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/commits/$sha/status"
|
||||
body=$(mktemp)
|
||||
elapsed=0
|
||||
saw_ci=0
|
||||
no_signal=0
|
||||
while [ "$elapsed" -lt "$CI_TIMEOUT" ]; do
|
||||
code=$(printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" | \
|
||||
curl -sS -o "$body" -w '%{http_code}' --config - "$api" 2>/dev/null) || code="000"
|
||||
state=$(sed -n 's/.*"state"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$body" | head -n1)
|
||||
|
||||
case "$code" in
|
||||
200)
|
||||
case "$state" in
|
||||
success)
|
||||
rm -f "$body"
|
||||
echo "CI: green for $sha after ${elapsed}s"
|
||||
return 0
|
||||
;;
|
||||
failure|error)
|
||||
rm -f "$body"
|
||||
echo "ERROR: CI $state for $sha — refusing to deploy."
|
||||
echo " See $GITEA_URL/$GITEA_OWNER/$GITEA_REPO/actions"
|
||||
return 1
|
||||
;;
|
||||
pending|"")
|
||||
saw_ci=1
|
||||
no_signal=0
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
404)
|
||||
rm -f "$body"
|
||||
echo "WARN: Gitea has no CI status for $sha (404)."
|
||||
echo " Is Gitea Actions configured with a runner for $GITEA_OWNER/$GITEA_REPO?"
|
||||
echo " Proceeding without a gate."
|
||||
return 0
|
||||
;;
|
||||
401|403)
|
||||
rm -f "$body"
|
||||
echo "WARN: GITEA_TOKEN rejected by Gitea ($code) — cannot verify CI."
|
||||
echo " Fix the token to enforce the gate; proceeding without one."
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
no_signal=$((no_signal + 1))
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$saw_ci" -eq 0 ] && [ "$no_signal" -ge 4 ]; then
|
||||
rm -f "$body"
|
||||
echo "WARN: no CI signal from Gitea after ${elapsed}s (last code=$code)."
|
||||
echo " CI may be down or misconfigured; proceeding without a gate."
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep "$CI_POLL_INTERVAL"
|
||||
elapsed=$((elapsed + CI_POLL_INTERVAL))
|
||||
printf '\rCI: waiting (%ss, code=%s state=%s)...' "$elapsed" "$code" "${state:-none}"
|
||||
done
|
||||
rm -f "$body"
|
||||
echo ""
|
||||
echo "ERROR: CI did not reach a terminal state within ${CI_TIMEOUT}s for $sha — refusing to deploy."
|
||||
return 1
|
||||
}
|
||||
verify_ci "$REMOTE_SHA" || exit 1
|
||||
|
||||
# ── 2. Update working tree to the verified commit ────────────────────────
|
||||
echo "[2/6] git pull (ff-only)"
|
||||
git pull --ff-only origin main
|
||||
# TOCTOU guard: origin/main may have advanced during the CI wait; refuse to
|
||||
# ship an unverified commit — a retry verifies the new tip.
|
||||
PULLED_FULL=$(git rev-parse HEAD)
|
||||
if [ "$PULLED_FULL" != "$REMOTE_FULL" ]; then
|
||||
echo "ERROR: origin/main advanced during deploy (verified $REMOTE_SHA, now at $(git rev-parse --short HEAD)) — aborting; retry verifies the new tip"
|
||||
exit 1
|
||||
fi
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
echo "SHA (deployed): $SHA"
|
||||
|
||||
# Resolve the deploy version AFTER pull (D2) so the tag matches the code
|
||||
# being built. Compose interpolates $OIKOS_VERSION into the image: tag.
|
||||
VERSION_FILE="$REPO_DIR/VERSION"
|
||||
if [ -f "$VERSION_FILE" ]; then
|
||||
OIKOS_VERSION="v$(head -n1 "$VERSION_FILE" | tr -d '[:space:]')"
|
||||
export OIKOS_VERSION
|
||||
echo "VERSION: $OIKOS_VERSION"
|
||||
else
|
||||
echo "WARNING: VERSION file missing — image will use :latest (rollback unavailable)"
|
||||
fi
|
||||
|
||||
# ── 3. Build version-tagged image (D2) ───────────────────────────────────
|
||||
echo "[3/6] docker compose build"
|
||||
DOCKER_BUILDKIT=1 docker compose -f "$COMPOSE_FILE" build \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1
|
||||
|
||||
# ── 4. Rolling restart ───────────────────────────────────────────────────
|
||||
echo "[4/6] docker compose up -d"
|
||||
docker compose -f "$COMPOSE_FILE" up -d --remove-orphans
|
||||
|
||||
# ── 5. Prune old image tags — keep the 3 newest so rollback ──────────────
|
||||
# (OIKOS_VERSION=v0.x.y docker compose up) stays available.
|
||||
echo "[5/6] prune old image tags (keep 3)"
|
||||
if [ -n "$OIKOS_VERSION" ]; then
|
||||
images=$(docker compose -f "$COMPOSE_FILE" config --images 2>/dev/null || true)
|
||||
if [ -z "$images" ]; then
|
||||
images="oikos-web"
|
||||
fi
|
||||
printf '%s\n' $images | sed 's/:.*//' | sort -u | while read -r repo; do
|
||||
docker image ls "$repo" --format '{{.Tag}}' 2>/dev/null | grep '^v' | sort -rV | tail -n +4 | while read -r tag; do
|
||||
docker rmi "$repo:$tag" >/dev/null 2>&1 || true
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
# ── 6. Health check wait (SPA answers on the published port) ─────────────
|
||||
echo "[6/6] health check"
|
||||
healthy=0
|
||||
for i in $(seq 1 $RETRIES); do
|
||||
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
|
||||
echo "healthy after ${i}x${SLEEP}s"
|
||||
healthy=1
|
||||
break
|
||||
fi
|
||||
sleep "$SLEEP"
|
||||
done
|
||||
if [ "$healthy" -ne 1 ]; then
|
||||
echo "ERROR: health check failed after $((RETRIES * SLEEP))s"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# All steps completed successfully — clear failure trap
|
||||
_ok=1
|
||||
exit 0
|
||||
86
scripts/install-webhook.sh
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/bin/sh
|
||||
# Install the oikos-web deploy-webhook launchd unit on the mac-mini.
|
||||
#
|
||||
# Renders scripts/oikos-web-deploy-webhook.plist with real secret values and
|
||||
# loads it. Values come from env; when absent, the HMAC secret and API token
|
||||
# are resolved from the oikos stack's Infisical via the oikos CLI (only
|
||||
# available on the mac-mini with the oikos checkout + .env).
|
||||
#
|
||||
# Usage:
|
||||
# WEBHOOK_HMAC_SECRET=... GITEA_TOKEN=... ./scripts/install-webhook.sh
|
||||
set -e
|
||||
|
||||
REPO_DIR="${REPO_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
PLIST_DST="$HOME/Library/LaunchAgents/network.hubris.oikos-web-deploy-webhook.plist"
|
||||
LABEL="network.hubris.oikos-web-deploy-webhook"
|
||||
|
||||
OIKOS_CLI="${OIKOS_CLI:-$HOME/Projects/oikos/oikos}"
|
||||
infisical_get() {
|
||||
[ -x "$OIKOS_CLI" ] || return 1
|
||||
(cd "$HOME/Projects/oikos" && set -a && . ./.env 2>/dev/null && set +a \
|
||||
&& OIKOS_INFISICAL_SITE_URL=http://localhost:8080 "$OIKOS_CLI" secret get "$1" 2>/dev/null) \
|
||||
| grep -E '^[0-9a-f]{40,}$' | head -n1
|
||||
}
|
||||
|
||||
HMAC="${WEBHOOK_HMAC_SECRET:-$(infisical_get webhook_hmac-secret || true)}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-$(infisical_get gitea-pat_token || true)}"
|
||||
API_TOKEN="${OIKOS_API_TOKEN:-$(infisical_get api_token || true)}"
|
||||
|
||||
if [ -z "$HMAC" ]; then
|
||||
echo "ERROR: WEBHOOK_HMAC_SECRET not set and could not resolve from Infisical" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the webhook binary first
|
||||
(cd "$REPO_DIR" && make webhook)
|
||||
|
||||
launchctl bootout "gui/$(id -u)" "$PLIST_DST" 2>/dev/null || true
|
||||
|
||||
cat > "$PLIST_DST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$REPO_DIR/webhook</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>$REPO_DIR</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>HOME</key>
|
||||
<string>$HOME</string>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
<key>WEBHOOK_LISTEN</key>
|
||||
<string>:9798</string>
|
||||
<key>WEBHOOK_REPO_DIR</key>
|
||||
<string>$REPO_DIR</string>
|
||||
<key>WEBHOOK_HMAC_SECRET</key>
|
||||
<string>$HMAC</string>
|
||||
<key>GITEA_URL</key>
|
||||
<string>https://git.hubris.network</string>
|
||||
<key>GITEA_TOKEN</key>
|
||||
<string>$GITEA_TOKEN</string>
|
||||
<key>OIKOS_API_TOKEN</key>
|
||||
<string>$API_TOKEN</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>$HOME/Library/Logs/oikos-web-webhook.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>$HOME/Library/Logs/oikos-web-webhook.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
chmod 600 "$PLIST_DST"
|
||||
|
||||
launchctl bootstrap "gui/$(id -u)" "$PLIST_DST"
|
||||
sleep 1
|
||||
launchctl print "gui/$(id -u)/$LABEL" >/dev/null && echo "installed: $LABEL (listening :9798)"
|
||||
38
scripts/oikos-web-deploy-webhook.plist
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>oikos-web-deploy-webhook</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/dtoro/Projects/oikos-web/webhook</string>
|
||||
</array>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>WEBHOOK_LISTEN</key>
|
||||
<string>:9798</string>
|
||||
<key>WEBHOOK_REPO_DIR</key>
|
||||
<string>/Users/dtoro/Projects/oikos-web</string>
|
||||
<!-- Rendered by install-webhook.sh at install time (same values as
|
||||
the oikos stack: HMAC from Infisical webhook_hmac-secret, Gitea
|
||||
PAT for the CI gate, Oikos API token for failure events). -->
|
||||
<key>WEBHOOK_HMAC_SECRET</key>
|
||||
<string>SET_AT_INSTALL</string>
|
||||
<key>GITEA_URL</key>
|
||||
<string>https://git.hubris.network</string>
|
||||
<key>GITEA_TOKEN</key>
|
||||
<string>SET_AT_INSTALL</string>
|
||||
<key>OIKOS_API_TOKEN</key>
|
||||
<string>SET_AT_INSTALL</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/oikos-web-deploy-webhook.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/oikos-web-deploy-webhook.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
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>
|
||||